Compare commits
71 Commits
feat/docke
...
feat/smtp-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09a190d6fb | ||
|
|
5586c26c12 | ||
|
|
2d7097eb83 | ||
|
|
2aa898da00 | ||
|
|
a01a826bac | ||
|
|
a29ea76024 | ||
|
|
e1c6bfd6cd | ||
|
|
c272c38f6b | ||
|
|
02d3d300fb | ||
|
|
0bd28f9f95 | ||
|
|
d0c2314ebf | ||
|
|
268871c348 | ||
|
|
2e11199764 | ||
|
|
fc6bb22400 | ||
|
|
1c75a948f9 | ||
|
|
3a48c797b7 | ||
|
|
b838e8040a | ||
|
|
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 |
73
.env.example
73
.env.example
@@ -1,21 +1,74 @@
|
|||||||
POSTGRES_URL=
|
# https://github.com/kanbn/kan?tab=readme-ov-file#environment-variables-)
|
||||||
|
|
||||||
EMAIL_FROM=
|
# Required environment variables
|
||||||
EMAIL_URL=
|
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
||||||
EMAIL_TOKEN=
|
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||||
|
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
|
||||||
|
|
||||||
NEXT_PUBLIC_BASE_URL=
|
# Only required if deploying from compose
|
||||||
NEXT_PUBLIC_STORAGE_URL=
|
POSTGRES_PASSWORD=
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
|
||||||
|
|
||||||
|
# SMTP (optional)
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=465 # Port 587 will not work
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
||||||
|
|
||||||
|
# S3 storage (optional)
|
||||||
S3_REGION=
|
S3_REGION=
|
||||||
S3_ENDPOINT=
|
S3_ENDPOINT=
|
||||||
S3_ACCESS_KEY_ID=
|
S3_ACCESS_KEY_ID=
|
||||||
S3_SECRET_ACCESS_KEY=
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
NEXT_PUBLIC_STORAGE_URL=
|
||||||
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||||
|
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||||
|
|
||||||
BETTER_AUTH_SECRET=
|
# Auth config (optional)
|
||||||
BETTER_AUTH_URL=
|
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||||
|
|
||||||
|
# Integration providers (optional)
|
||||||
|
TRELLO_APP_API_KEY=
|
||||||
|
TRELLO_APP_SECRET=
|
||||||
|
|
||||||
|
# OAuth providers (optional)
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
DISCORD_CLIENT_ID=
|
||||||
|
DISCORD_CLIENT_SECRET=
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
|
GITLAB_CLIENT_ID=
|
||||||
|
GITLAB_CLIENT_SECRET=
|
||||||
|
GITLAB_ISSUER=
|
||||||
|
MICROSOFT_CLIENT_ID=
|
||||||
|
MICROSOFT_CLIENT_SECRET=
|
||||||
|
TWITTER_CLIENT_ID=
|
||||||
|
TWITTER_CLIENT_SECRET=
|
||||||
|
KICK_CLIENT_ID=
|
||||||
|
KICK_CLIENT_SECRET=
|
||||||
|
ZOOM_CLIENT_ID=
|
||||||
|
ZOOM_CLIENT_SECRET=
|
||||||
|
DROPBOX_CLIENT_ID=
|
||||||
|
DROPBOX_CLIENT_SECRET=
|
||||||
|
VK_CLIENT_ID=
|
||||||
|
VK_CLIENT_SECRET=
|
||||||
|
LINKEDIN_CLIENT_ID=
|
||||||
|
LINKEDIN_CLIENT_SECRET=
|
||||||
|
REDDIT_CLIENT_ID=
|
||||||
|
REDDIT_CLIENT_SECRET=
|
||||||
|
ROBLOX_CLIENT_ID=
|
||||||
|
ROBLOX_CLIENT_SECRET=
|
||||||
|
SPOTIFY_CLIENT_ID=
|
||||||
|
SPOTIFY_CLIENT_SECRET=
|
||||||
|
TIKTOK_CLIENT_ID=
|
||||||
|
TIKTOK_CLIENT_SECRET=
|
||||||
|
TIKTOK_CLIENT_KEY=
|
||||||
|
TWITCH_CLIENT_ID=
|
||||||
|
TWITCH_CLIENT_SECRET=
|
||||||
|
APPLE_CLIENT_ID=
|
||||||
|
APPLE_CLIENT_SECRET=
|
||||||
|
APPLE_APP_BUNDLE_IDENTIFIER=
|
||||||
|
|
||||||
|
|||||||
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}
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,3 +46,6 @@ dist/
|
|||||||
|
|
||||||
# docker
|
# docker
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# runtime env
|
||||||
|
__ENV.js
|
||||||
125
README.md
125
README.md
@@ -45,6 +45,70 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
|||||||
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
|
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
|
||||||
- [React Email](https://react.email/?ref=kan.bn)
|
- [React Email](https://react.email/?ref=kan.bn)
|
||||||
|
|
||||||
|
## Self Hosting 🐳
|
||||||
|
|
||||||
|
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
|
||||||
|
|
||||||
|
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: ghcr.io/kanbn/kan:latest
|
||||||
|
container_name: kan-web
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
networks:
|
||||||
|
- kan-network
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_BASE_URL: http://localhost:3000
|
||||||
|
BETTER_AUTH_SECRET: your_auth_secret
|
||||||
|
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
|
||||||
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
container_name: kan-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: kan_db
|
||||||
|
POSTGRES_USER: kan
|
||||||
|
POSTGRES_PASSWORD: your_postgres_password
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
volumes:
|
||||||
|
- kan_postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- kan-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
kan-network:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
kan_postgres_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the containers in detached mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Access Kan at http://localhost:3000
|
||||||
|
|
||||||
|
The application will be running in the background. You can manage the containers using these commands:
|
||||||
|
|
||||||
|
- To stop the containers: `docker compose down`
|
||||||
|
- To view logs: `docker compose logs -f`
|
||||||
|
- To restart the containers: `docker compose restart`
|
||||||
|
|
||||||
|
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
||||||
|
|
||||||
|
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
|
||||||
|
|
||||||
## Local Development 🧑💻
|
## Local Development 🧑💻
|
||||||
|
|
||||||
1. Clone the repository (or fork)
|
1. Clone the repository (or fork)
|
||||||
@@ -60,7 +124,13 @@ pnpm install
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Copy `.env.example` to `.env` and configure your environment variables
|
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
|
```bash
|
||||||
pnpm dev
|
pnpm dev
|
||||||
@@ -68,25 +138,34 @@ pnpm dev
|
|||||||
|
|
||||||
## Environment Variables 🔐
|
## Environment Variables 🔐
|
||||||
|
|
||||||
| Variable | Description | Required | Example |
|
| Variable | Description | Required | Example |
|
||||||
| -------------------------------- | ----------------------------- | ---------------- | --------------------------------------------- |
|
| -------------------------------- | ----------------------------- | ------------------ | --------------------------------------------- |
|
||||||
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
||||||
| `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
|
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||||
| `EMAIL_URL` | Email service API URL | Yes | `https://api.resend.com/emails` |
|
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||||
| `EMAIL_TOKEN` | Email service API token | Yes | `re_xxxx` |
|
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
| `SMTP_USER` | SMTP username/email | For Email | `resend` |
|
||||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
| `SMTP_PASSWORD` | SMTP password/token | For Email | `re_xxxx` |
|
||||||
| `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
|
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | For Google login | `http://localhost:3000,http://localhost:3001` |
|
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `storage.kanbn.com` |
|
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
| `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.
|
See `.env.example` for a complete list of supported environment variables.
|
||||||
|
|
||||||
@@ -94,6 +173,12 @@ See `.env.example` for a complete list of supported environment variables.
|
|||||||
|
|
||||||
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
|
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
|
||||||
|
|
||||||
|
## Contributors 👥
|
||||||
|
|
||||||
|
<a href="https://github.com/kanbn/kan/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
|
||||||
|
</a>
|
||||||
|
|
||||||
## License 📝
|
## License 📝
|
||||||
|
|
||||||
Kan is licensed under the [AGPLv3 license](LICENSE).
|
Kan is licensed under the [AGPLv3 license](LICENSE).
|
||||||
|
|||||||
@@ -3,31 +3,25 @@ title: Trello
|
|||||||
description: "Import your Trello boards to Kan."
|
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**
|
1. **Authorize Kan.bn for Trello**
|
||||||
Go to [https://trello.com](https://trello.com) and log in with your Trello account.
|
Go to [https://kan.bn/settings](https://kan.bn/settings) and click **Connect Trello**.
|
||||||
|
|
||||||
2. **Create a new integration**
|
2. **Log in to your Trello account**
|
||||||
Visit [https://trello.com/power-ups/admin](https://trello.com/power-ups/admin). Click **Create new** and fill in the following details:
|
You will be redirected to Trello to log in to your Trello account.
|
||||||
|
|
||||||
- **Name**: Choose a name for your integration (e.g. "Kan Import")
|
3. **Authorize Kan.bn for Trello**
|
||||||
- **Workspace**: Select the workspace containing the boards you want to import
|
Approve the permissions and you will be redirected back to Kan.bn.
|
||||||
- **Email**: Enter your email address
|
|
||||||
- **Support email**: Enter your email address
|
|
||||||
- **Author**: Add your name
|
|
||||||
|
|
||||||
3. **Get a Trello API Key**
|
4. **Verify the connection**
|
||||||
Now click **Generate a new API key** to create an API key.
|
You should see a **Disconnect Trello** button.
|
||||||
|
|
||||||
4. **Generate a Trello Token**
|
|
||||||
On the same page (to the right of the API key), click the link to **generate a Token**. Approve the permissions and copy your token (make sure to save it somewhere secure like a password manager).
|
|
||||||
|
|
||||||
## Importing your Trello boards
|
## Importing your Trello boards
|
||||||
|
|
||||||
Once you have 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**
|
1. **Create a new import in Kan**
|
||||||
Navigate to **[https://kan.bn/boards](https://kan.dev/boards)** and click **Import**.
|
Navigate to **[https://kan.bn/boards](https://kan.dev/boards)** and click **Import**.
|
||||||
@@ -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**
|
2. **Select Trello as the Import Source**
|
||||||
Choose **Trello** from the list of import options and click **Select source**.
|
Choose **Trello** from the list of import options and click **Select source**.
|
||||||
|
|
||||||
3. **Enter Your Trello API Key and Token**
|
3. **Start the Import**
|
||||||
Paste your API key and token into the provided fields and click **Fetch boards** (don't worry, we don't store your key or token).
|
|
||||||
|
|
||||||
4. **Start the Import**
|
|
||||||
Select the board you want to import and click **Import boards**. Once the import is complete, you'll see your boards in Kan.
|
Select the board you want to import and click **Import boards**. Once the import is complete, you'll see your boards in Kan.
|
||||||
|
|||||||
@@ -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
|
Get started instantly with our hosted solution - we'll handle all the
|
||||||
hosting, scaling, and maintenance for you.
|
hosting, scaling, and maintenance for you.
|
||||||
</Card>
|
</Card>
|
||||||
<Card 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
|
For full data ownership and control, you can self-host Kan on your
|
||||||
infrastructure.
|
infrastructure.
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ COPY . .
|
|||||||
|
|
||||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||||
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||||
RUN turbo prune --scope=${PROJECT} --docker
|
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||||
|
|
||||||
# 3. Build the project
|
# 3. Build the project
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
@@ -53,8 +53,14 @@ RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfi
|
|||||||
# Copy source code of isolated subworkspace
|
# Copy source code of isolated subworkspace
|
||||||
COPY --from=pruner /app/out/full/ .
|
COPY --from=pruner /app/out/full/ .
|
||||||
|
|
||||||
|
|
||||||
RUN pnpm build --filter=${PROJECT}
|
RUN pnpm build --filter=${PROJECT}
|
||||||
|
|
||||||
|
# # Copy static files to standalone directory
|
||||||
|
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||||
|
# mv apps/web/public apps/web/.next/standalone/ && \
|
||||||
|
# mv apps/web/.next/static apps/web/.next/standalone/.next/
|
||||||
|
|
||||||
# 4. Final image - runner stage to run the application
|
# 4. Final image - runner stage to run the application
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
ARG APP_DIRNAME
|
ARG APP_DIRNAME
|
||||||
@@ -75,5 +81,5 @@ ARG PORT=3000
|
|||||||
ENV PORT=${PORT}
|
ENV PORT=${PORT}
|
||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
CMD ["pnpm", "start"]
|
CMD ["sh", "-c", "cd /app && pnpm db:migrate && cd /app/apps/web && pnpm start"]
|
||||||
|
|
||||||
|
|||||||
23
apps/web/entrypoint.sh
Normal file
23
apps/web/entrypoint.sh
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Run migrations from the Turborepo root context
|
||||||
|
echo "Running database migrations..."
|
||||||
|
# Navigate to the Turborepo root to run the db:migrate command
|
||||||
|
pnpm --prefix /app db:migrate
|
||||||
|
|
||||||
|
# Check if migration was successful
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "\nDatabase migration failed! Exiting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "\nStarting Next.js application..."
|
||||||
|
|
||||||
|
# Check if we should use standalone mode
|
||||||
|
if [ "$NEXT_PUBLIC_USE_STANDALONE_OUTPUT" = "true" ]; then
|
||||||
|
echo "Starting in standalone mode..."
|
||||||
|
exec node .next/standalone/apps/web/server.js
|
||||||
|
else
|
||||||
|
echo "Starting in standard mode..."
|
||||||
|
exec pnpm start
|
||||||
|
fi
|
||||||
@@ -10,6 +10,10 @@ configureRuntimeEnv();
|
|||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
output:
|
||||||
|
env("NEXT_PUBLIC_USE_STANDALONE_OUTPUT") === "true"
|
||||||
|
? "standalone"
|
||||||
|
: undefined,
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
/** Enables hot reloading for local packages without a build step */
|
/** Enables hot reloading for local packages without a build step */
|
||||||
@@ -26,7 +30,20 @@ const config = {
|
|||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
domains: [env("NEXT_PUBLIC_STORAGE_DOMAIN") ?? ""],
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
hostname: "localhost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "*.googleusercontent.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
instrumentationHook: true,
|
instrumentationHook: true,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm next build",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||||
"dev": "pnpm with-env next dev | pino-pretty",
|
"dev": "pnpm with-env next dev | pino-pretty",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
@@ -23,7 +23,13 @@
|
|||||||
"@kan/db": "workspace:^",
|
"@kan/db": "workspace:^",
|
||||||
"@kan/shared": "workspace:^",
|
"@kan/shared": "workspace:^",
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
|
"@tiptap/extension-placeholder": "^2.14.0",
|
||||||
|
"@tiptap/pm": "^2.14.0",
|
||||||
|
"@tiptap/react": "^2.14.0",
|
||||||
|
"@tiptap/starter-kit": "^2.14.0",
|
||||||
|
"@tiptap/suggestion": "^2.14.0",
|
||||||
"@trpc/client": "catalog:",
|
"@trpc/client": "catalog:",
|
||||||
"@trpc/next": "^11.0.0-rc.660",
|
"@trpc/next": "^11.0.0-rc.660",
|
||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
@@ -31,22 +37,23 @@
|
|||||||
"aws-sdk": "^2.1692.0",
|
"aws-sdk": "^2.1692.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"js-cookie": "^3.0.5",
|
|
||||||
"jwt-decode": "^4.0.0",
|
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"next-logger": "^5.0.1",
|
"next-logger": "^5.0.1",
|
||||||
"next-runtime-env": "^1.7.2",
|
"next-runtime-env": "^1.7.2",
|
||||||
"nextjs-cors": "^2.2.0",
|
"nextjs-cors": "^2.2.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
|
"posthog-js": "^1.254.0",
|
||||||
"react": "catalog:react18",
|
"react": "catalog:react18",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-contenteditable": "^3.3.7",
|
"react-contenteditable": "^3.3.7",
|
||||||
"react-dom": "catalog:react18",
|
"react-dom": "catalog:react18",
|
||||||
"react-hook-form": "^7.51.1",
|
"react-hook-form": "^7.51.1",
|
||||||
"react-icons": "^4.12.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-lottie-player": "^1.5.5",
|
"react-lottie-player": "^1.5.5",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -55,7 +62,6 @@
|
|||||||
"@kan/stripe": "workspace:*",
|
"@kan/stripe": "workspace:*",
|
||||||
"@kan/tailwind-config": "workspace:*",
|
"@kan/tailwind-config": "workspace:*",
|
||||||
"@kan/tsconfig": "workspace:*",
|
"@kan/tsconfig": "workspace:*",
|
||||||
"@types/js-cookie": "^3.0.6",
|
|
||||||
"@types/node": "^20.17.7",
|
"@types/node": "^20.17.7",
|
||||||
"@types/react": "catalog:react18",
|
"@types/react": "catalog:react18",
|
||||||
"@types/react-beautiful-dnd": "^13.1.7",
|
"@types/react-beautiful-dnd": "^13.1.7",
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
window.__ENV = {
|
|
||||||
NEXT_PUBLIC_UMAMI_ID: "aaed1f55-25e2-4223-b918-e429c390be35",
|
|
||||||
NEXT_PUBLIC_KAN_ENV: "cloud",
|
|
||||||
NEXT_PUBLIC_BASE_URL: "http://localhost:3000",
|
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "avatars",
|
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: "storage.kanbn.com",
|
|
||||||
NEXT_PUBLIC_STORAGE_URL: "https://storage.kanbn.com",
|
|
||||||
};
|
|
||||||
@@ -1,115 +1,338 @@
|
|||||||
|
import type { SocialProvider } from "better-auth/social-providers";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { 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 { z } from "zod";
|
||||||
|
|
||||||
import { authClient } from "@kan/auth";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
|
name?: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthProps {
|
interface AuthProps {
|
||||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||||
|
isSignUp?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmailSchema = z.object({ 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 availableSocialProviders = {
|
||||||
const [isLoginWithGooglePending, setIsLoginWithGooglePending] =
|
google: {
|
||||||
useState(false);
|
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 [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
|
watch,
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
resolver: zodResolver(EmailSchema),
|
resolver: zodResolver(EmailSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const 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);
|
setIsLoginWithEmailPending(true);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
const { error } = await authClient.signIn.magicLink({
|
if (password) {
|
||||||
email,
|
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",
|
callbackURL: "/boards",
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsLoginWithEmailPending(false);
|
setIsLoginWithProviderPending(null);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setLoginError(
|
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) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
await handleLoginWithEmail(values.email);
|
await handleLoginWithEmail(values.email, values.password, values.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const password = watch("password");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
{socialProviders?.length !== 0 && (
|
||||||
<Button
|
<div className="space-y-2">
|
||||||
onClick={handleLoginWithGoogle}
|
{Object.entries(availableSocialProviders).map(([key, provider]) => {
|
||||||
isLoading={isLoginWithGooglePending}
|
if (!socialProviders?.includes(key)) {
|
||||||
iconLeft={<FaGoogle />}
|
return null;
|
||||||
fullWidth
|
}
|
||||||
size="lg"
|
return (
|
||||||
>
|
<Button
|
||||||
Continue with Google
|
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||||
</Button>
|
isLoading={isLoginWithProviderPending === key}
|
||||||
</div>
|
iconLeft={<provider.icon />}
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
fullWidth
|
||||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
size="lg"
|
||||||
<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>
|
Continue with {provider.name}
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Input
|
)}
|
||||||
{...register("email", { required: true })}
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
placeholder="Enter your email address"
|
{socialProviders?.length !== 0 && (
|
||||||
/>
|
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||||
{errors.email && (
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
<p className="mt-2 text-xs text-red-400">
|
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||||
Please enter a valid email address
|
or
|
||||||
</p>
|
</span>
|
||||||
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{loginError && (
|
<div className="space-y-2">
|
||||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
{isSignUp && isCredentialsEnabled && (
|
||||||
)}
|
<div>
|
||||||
<div className="mt-[1.5rem]">
|
<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
|
<Button
|
||||||
isLoading={isLoginWithEmailPending}
|
isLoading={isLoginWithEmailPending}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
Continue with email
|
{isSignUp ? "Sign up with " : "Continue with "}
|
||||||
|
{!isCredentialsEnabled || (password && password.length !== 0)
|
||||||
|
? "email"
|
||||||
|
: "magic link"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
className={twMerge(
|
className={twMerge(
|
||||||
"block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6",
|
"block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6",
|
||||||
prefix && "rounded-l-none",
|
prefix && "rounded-l-none",
|
||||||
|
type === "password" && "pr-8",
|
||||||
className && className,
|
className && className,
|
||||||
)}
|
)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
@@ -75,7 +76,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
{type === "password" && (
|
{type === "password" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 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}
|
tabIndex={-1}
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Menu, Transition } from "@headlessui/react";
|
import { Menu, Transition } from "@headlessui/react";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
|
|
||||||
import { authClient } from "@kan/auth";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import { useTheme } from "~/providers/theme";
|
import { useTheme } from "~/providers/theme";
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
|
|||||||
@@ -15,19 +15,61 @@ export const env = createEnv({
|
|||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
BETTER_AUTH_SECRET: z.string(),
|
BETTER_AUTH_SECRET: z.string(),
|
||||||
BETTER_AUTH_URL: z.string(),
|
BETTER_AUTH_TRUSTED_ORIGINS: z
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS: z.string(),
|
.string()
|
||||||
|
.transform((s) => (s === "" ? undefined : s))
|
||||||
|
.refine(
|
||||||
|
(s) =>
|
||||||
|
!s ||
|
||||||
|
s.split(",").every((l) => z.string().url().safeParse(l).success),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
POSTGRES_URL: z.string().url(),
|
POSTGRES_URL: z.string().url(),
|
||||||
|
TRELLO_APP_API_KEY: z.string().optional(),
|
||||||
|
TRELLO_APP_SECRET: z.string().optional(),
|
||||||
STRIPE_SECRET_KEY: z.string().optional(),
|
STRIPE_SECRET_KEY: z.string().optional(),
|
||||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||||
|
DISCORD_CLIENT_ID: z.string().optional(),
|
||||||
|
DISCORD_CLIENT_SECRET: z.string().optional(),
|
||||||
|
GITHUB_CLIENT_ID: z.string().optional(),
|
||||||
|
GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||||
|
GITLAB_CLIENT_ID: z.string().optional(),
|
||||||
|
GITLAB_CLIENT_SECRET: z.string().optional(),
|
||||||
|
GITLAB_ISSUER: z.string().optional(),
|
||||||
|
MICROSOFT_CLIENT_ID: z.string().optional(),
|
||||||
|
MICROSOFT_CLIENT_SECRET: z.string().optional(),
|
||||||
|
TWITTER_CLIENT_ID: z.string().optional(),
|
||||||
|
TWITTER_CLIENT_SECRET: z.string().optional(),
|
||||||
|
KICK_CLIENT_ID: z.string().optional(),
|
||||||
|
KICK_CLIENT_SECRET: z.string().optional(),
|
||||||
|
ZOOM_CLIENT_ID: z.string().optional(),
|
||||||
|
ZOOM_CLIENT_SECRET: z.string().optional(),
|
||||||
|
DROPBOX_CLIENT_ID: z.string().optional(),
|
||||||
|
DROPBOX_CLIENT_SECRET: z.string().optional(),
|
||||||
|
VK_CLIENT_ID: z.string().optional(),
|
||||||
|
VK_CLIENT_SECRET: z.string().optional(),
|
||||||
|
LINKEDIN_CLIENT_ID: z.string().optional(),
|
||||||
|
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
||||||
|
REDDIT_CLIENT_ID: z.string().optional(),
|
||||||
|
REDDIT_CLIENT_SECRET: z.string().optional(),
|
||||||
|
ROBLOX_CLIENT_ID: z.string().optional(),
|
||||||
|
ROBLOX_CLIENT_SECRET: z.string().optional(),
|
||||||
|
SPOTIFY_CLIENT_ID: z.string().optional(),
|
||||||
|
SPOTIFY_CLIENT_SECRET: z.string().optional(),
|
||||||
|
TIKTOK_CLIENT_ID: z.string().optional(),
|
||||||
|
TIKTOK_CLIENT_SECRET: z.string().optional(),
|
||||||
|
TIKTOK_CLIENT_KEY: z.string().optional(),
|
||||||
|
TWITCH_CLIENT_ID: z.string().optional(),
|
||||||
|
TWITCH_CLIENT_SECRET: z.string().optional(),
|
||||||
|
APPLE_CLIENT_ID: z.string().optional(),
|
||||||
|
APPLE_CLIENT_SECRET: z.string().optional(),
|
||||||
|
APPLE_APP_BUNDLE_IDENTIFIER: z.string().optional(),
|
||||||
S3_ACCESS_KEY_ID: z.string().optional(),
|
S3_ACCESS_KEY_ID: z.string().optional(),
|
||||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||||
S3_REGION: z.string().optional(),
|
S3_REGION: z.string().optional(),
|
||||||
S3_ENDPOINT: z.string().optional(),
|
S3_ENDPOINT: z.string().optional(),
|
||||||
EMAIL_FROM: z.string(),
|
EMAIL_FROM: z.string().optional(),
|
||||||
EMAIL_URL: z.string(),
|
|
||||||
EMAIL_TOKEN: z.string(),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,10 +79,27 @@ export const env = createEnv({
|
|||||||
client: {
|
client: {
|
||||||
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
|
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
|
||||||
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
|
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
|
||||||
NEXT_PUBLIC_BASE_URL: z.string(),
|
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
||||||
NEXT_PUBLIC_STORAGE_URL: z.string().optional(),
|
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
||||||
|
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: z.string().optional(),
|
||||||
|
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||||
|
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||||
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||||
|
.string()
|
||||||
|
.transform((s) => (s === "" ? undefined : s))
|
||||||
|
.refine(
|
||||||
|
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGN_UP: z
|
||||||
|
.string()
|
||||||
|
.transform((s) => (s === "" ? undefined : s))
|
||||||
|
.refine(
|
||||||
|
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||||
@@ -49,10 +108,16 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_KAN_ENV: process.env.NEXT_PUBLIC_KAN_ENV,
|
NEXT_PUBLIC_KAN_ENV: process.env.NEXT_PUBLIC_KAN_ENV,
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
NEXT_PUBLIC_UMAMI_ID: process.env.NEXT_PUBLIC_UMAMI_ID,
|
NEXT_PUBLIC_UMAMI_ID: process.env.NEXT_PUBLIC_UMAMI_ID,
|
||||||
|
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
|
||||||
|
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||||
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||||
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||||
|
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
||||||
|
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
|
||||||
},
|
},
|
||||||
skipValidation:
|
skipValidation:
|
||||||
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import "~/styles/globals.css";
|
|||||||
|
|
||||||
import type { AppType } from "next/app";
|
import type { AppType } from "next/app";
|
||||||
import { Plus_Jakarta_Sans } from "next/font/google";
|
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||||
|
import Script from "next/script";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
|
import posthog from "posthog-js";
|
||||||
|
import { PostHogProvider } from "posthog-js/react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { ModalProvider } from "~/providers/modal";
|
import { ModalProvider } from "~/providers/modal";
|
||||||
import { PopupProvider } from "~/providers/popup";
|
import { PopupProvider } from "~/providers/popup";
|
||||||
@@ -21,6 +25,21 @@ export const metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MyApp: AppType = ({ Component, pageProps }) => {
|
const MyApp: AppType = ({ Component, pageProps }) => {
|
||||||
|
const posthogKey = env("NEXT_PUBLIC_POSTHOG_KEY");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (posthogKey) {
|
||||||
|
posthog.init(posthogKey, {
|
||||||
|
api_host: env("NEXT_PUBLIC_POSTHOG_HOST"),
|
||||||
|
person_profiles: "identified_only",
|
||||||
|
defaults: "2025-05-24",
|
||||||
|
loaded: (posthog) => {
|
||||||
|
if (process.env.NODE_ENV === "development") posthog.debug();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [posthogKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
@@ -32,7 +51,7 @@ const MyApp: AppType = ({ Component, pageProps }) => {
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
{env("NEXT_PUBLIC_UMAMI_ID") && (
|
{env("NEXT_PUBLIC_UMAMI_ID") && (
|
||||||
<script
|
<Script
|
||||||
defer
|
defer
|
||||||
src="https://cloud.umami.is/script.js"
|
src="https://cloud.umami.is/script.js"
|
||||||
data-website-id={env("NEXT_PUBLIC_UMAMI_ID")}
|
data-website-id={env("NEXT_PUBLIC_UMAMI_ID")}
|
||||||
@@ -43,7 +62,13 @@ const MyApp: AppType = ({ Component, pageProps }) => {
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ModalProvider>
|
<ModalProvider>
|
||||||
<PopupProvider>
|
<PopupProvider>
|
||||||
<Component {...pageProps} />
|
{posthogKey ? (
|
||||||
|
<PostHogProvider client={posthog}>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</PostHogProvider>
|
||||||
|
) : (
|
||||||
|
<Component {...pageProps} />
|
||||||
|
)}
|
||||||
</PopupProvider>
|
</PopupProvider>
|
||||||
</ModalProvider>
|
</ModalProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { toNodeHandler } from "better-auth/node";
|
import { toNodeHandler } from "better-auth/node";
|
||||||
|
|
||||||
import { initAuth } from "@kan/auth";
|
import { initAuth } from "@kan/auth/server";
|
||||||
import { createDrizzleClient } from "@kan/db/client";
|
import { createDrizzleClient } from "@kan/db/client";
|
||||||
|
|
||||||
export const config = { api: { bodyParser: false } };
|
export const config = { api: { bodyParser: false } };
|
||||||
|
|
||||||
const auth = initAuth(createDrizzleClient());
|
export const auth = initAuth(createDrizzleClient());
|
||||||
|
|
||||||
export default toNodeHandler(auth.handler);
|
export default toNodeHandler(auth.handler);
|
||||||
|
|||||||
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,11 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
import { env } from "next-runtime-env";
|
import { env as nextRuntimeEnv } from "next-runtime-env";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
const allowedContentTypes = ["image/jpeg", "image/png"];
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ export default async function handler(
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
client,
|
client,
|
||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||||
Key: filename,
|
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>;
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@ export const formatMemberDisplayName = (
|
|||||||
return localPart.replace(/[_-]/g, ".");
|
return localPart.replace(/[_-]/g, ".");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAvatarUrl = (key: string) => {
|
export const getAvatarUrl = (imageOrKey: string) => {
|
||||||
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${key}`;
|
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 Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
// import { useRouter } from "next/navigation";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import { Auth } from "~/components/AuthForm";
|
import { Auth } from "~/components/AuthForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
|
|
||||||
// import { api } from "~/utils/api";
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
// const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isSignUpDisabled = env("NEXT_PUBLIC_DISABLE_SIGN_UP") === "true";
|
||||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||||
|
|
||||||
@@ -18,15 +20,9 @@ export default function LoginPage() {
|
|||||||
setMagicLinkRecipient(recipient);
|
setMagicLinkRecipient(recipient);
|
||||||
};
|
};
|
||||||
|
|
||||||
// const authCookieExists = document.cookie
|
const { data } = authClient.useSession();
|
||||||
// .split("; ")
|
|
||||||
// .some((cookie) => cookie.includes("auth-token"));
|
|
||||||
|
|
||||||
// const { data } = api.user.getUser.useQuery(undefined, {
|
if (data?.user.id) router.push("/boards");
|
||||||
// enabled: authCookieExists ? true : false,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// if (data?.id) router.push("/boards");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -55,12 +51,14 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
{!isSignUpDisabled && (
|
||||||
Don't have an account?{" "}
|
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||||
<span className="underline">
|
Don't have an account?{" "}
|
||||||
<Link href="/signup">Sign up</Link>
|
<span className="underline">
|
||||||
</span>
|
<Link href="/signup">Sign up</Link>
|
||||||
</p>
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PatternedBackground />
|
<PatternedBackground />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
// import { useRouter } from "next/navigation";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import { Auth } from "~/components/AuthForm";
|
import { Auth } from "~/components/AuthForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
// import { api } from "~/utils/api";
|
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
// const router = useRouter();
|
const router = useRouter();
|
||||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||||
|
|
||||||
@@ -18,15 +19,18 @@ export default function SignupPage() {
|
|||||||
setMagicLinkRecipient(recipient);
|
setMagicLinkRecipient(recipient);
|
||||||
};
|
};
|
||||||
|
|
||||||
// const authCookieExists = document.cookie
|
const { data } = authClient.useSession();
|
||||||
// .split("; ")
|
|
||||||
// .some((cookie) => cookie.includes("auth-token"));
|
|
||||||
|
|
||||||
// const { data } = api.user.getUser.useQuery(undefined, {
|
if (data?.user.id) router.push("/boards");
|
||||||
// enabled: authCookieExists ? true : false,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// 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 (
|
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="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
|
<Auth setIsMagicLinkSent={handleMagicLinkSent} isSignUp />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { type ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import {
|
|
||||||
HiOutlinePlusSmall,
|
|
||||||
HiEllipsisHorizontal,
|
|
||||||
HiOutlineTrash,
|
|
||||||
HiOutlineSquaresPlus,
|
|
||||||
} from "react-icons/hi2";
|
|
||||||
import { Draggable } from "react-beautiful-dnd";
|
import { Draggable } from "react-beautiful-dnd";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import {
|
||||||
import { api } from "~/utils/api";
|
HiEllipsisHorizontal,
|
||||||
import { useModal } from "~/providers/modal";
|
HiOutlinePlusSmall,
|
||||||
|
HiOutlineSquaresPlus,
|
||||||
|
HiOutlineTrash,
|
||||||
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
interface ListProps {
|
interface ListProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -80,20 +79,20 @@ export default function List({
|
|||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
|
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between">
|
<div className="mb-2 flex justify-between">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
className="focus-visible:outline-none"
|
className="w-full focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
onBlur={handleSubmit(onSubmit)}
|
onBlur={handleSubmit(onSubmit)}
|
||||||
className="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>
|
</form>
|
||||||
<div>
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
|
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
|
||||||
onClick={() => openNewCardForm(list.publicId)}
|
onClick={() => openNewCardForm(list.publicId)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { generateUID } from "@kan/shared/utils";
|
|||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||||
|
import Editor from "~/components/Editor";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import LabelIcon from "~/components/LabelIcon";
|
import LabelIcon from "~/components/LabelIcon";
|
||||||
import Toggle from "~/components/Toggle";
|
import Toggle from "~/components/Toggle";
|
||||||
@@ -63,6 +64,7 @@ export function NewCardForm({
|
|||||||
const memberPublicIds = watch("memberPublicIds") || [];
|
const memberPublicIds = watch("memberPublicIds") || [];
|
||||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||||
const position = watch("position");
|
const position = watch("position");
|
||||||
|
const title = watch("title");
|
||||||
|
|
||||||
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
||||||
enabled: !!boardPublicId,
|
enabled: !!boardPublicId,
|
||||||
@@ -110,16 +112,29 @@ export function NewCardForm({
|
|||||||
|
|
||||||
return { previousState: currentState };
|
return { previousState: currentState };
|
||||||
},
|
},
|
||||||
onError: (_error, _newList, context) => {
|
onError: (error, _newList, context) => {
|
||||||
utils.board.byId.setData(queryParams, context?.previousState);
|
utils.board.byId.setData(queryParams, context?.previousState);
|
||||||
showPopup({
|
showPopup({
|
||||||
header: "Unable to create card",
|
header: "Unable to create card",
|
||||||
message: "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",
|
icon: "error",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSuccess: async () => {
|
||||||
|
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||||
|
if (!isCreateAnotherEnabled) closeModal();
|
||||||
await utils.board.byId.invalidate(queryParams);
|
await utils.board.byId.invalidate(queryParams);
|
||||||
|
reset({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
listPublicId: watch("listPublicId"),
|
||||||
|
labelPublicIds: [],
|
||||||
|
memberPublicIds: [],
|
||||||
|
isCreateAnotherEnabled,
|
||||||
|
position,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,18 +179,6 @@ export function NewCardForm({
|
|||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
const onSubmit = (data: NewCardInput) => {
|
const onSubmit = (data: NewCardInput) => {
|
||||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
|
||||||
if (!isCreateAnotherEnabled) closeModal();
|
|
||||||
reset({
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
listPublicId: watch("listPublicId"),
|
|
||||||
labelPublicIds: [],
|
|
||||||
memberPublicIds: [],
|
|
||||||
isCreateAnotherEnabled,
|
|
||||||
position,
|
|
||||||
});
|
|
||||||
|
|
||||||
createCard.mutate({
|
createCard.mutate({
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
@@ -251,18 +254,12 @@ export function NewCardForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Input
|
<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">
|
||||||
placeholder="Add description..."
|
<Editor
|
||||||
onChange={(e) => setValue("description", e.target.value)}
|
content=""
|
||||||
value={watch("description")}
|
onChange={(value) => setValue("description", value)}
|
||||||
contentEditable
|
/>
|
||||||
onKeyDown={async (e) => {
|
</div>
|
||||||
if (e.key === "Enter" && e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
await handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex space-x-1">
|
<div className="mt-2 flex space-x-1">
|
||||||
<div className="w-fit">
|
<div className="w-fit">
|
||||||
@@ -387,7 +384,12 @@ export function NewCardForm({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button type="submit">Create card</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={title.length === 0 || createCard.isPending}
|
||||||
|
>
|
||||||
|
Create card
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { HiXMark } from "react-icons/hi2";
|
import { HiCheck, HiXMark } from "react-icons/hi2";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
|
import { useDebounce } from "~/hooks/useDebounce";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -49,6 +51,7 @@ export function UpdateBoardSlugForm({
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isDirty, errors },
|
formState: { isDirty, errors },
|
||||||
|
watch,
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
values: {
|
values: {
|
||||||
@@ -57,6 +60,10 @@ export function UpdateBoardSlugForm({
|
|||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const slug = watch("slug");
|
||||||
|
|
||||||
|
const [debouncedSlug] = useDebounce(slug, 500);
|
||||||
|
|
||||||
const updateBoardSlug = api.board.update.useMutation({
|
const updateBoardSlug = api.board.update.useMutation({
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showPopup({
|
showPopup({
|
||||||
@@ -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(() => {
|
useEffect(() => {
|
||||||
const nameElement: HTMLElement | null =
|
const nameElement: HTMLElement | null =
|
||||||
document.querySelector<HTMLElement>("#board-slug");
|
document.querySelector<HTMLElement>("#board-slug");
|
||||||
@@ -78,6 +99,9 @@ export function UpdateBoardSlugForm({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onSubmit = (data: FormValues) => {
|
const onSubmit = (data: FormValues) => {
|
||||||
|
if (!isBoardSlugAvailable) return;
|
||||||
|
if (isBoardSlugAvailable?.isReserved) return;
|
||||||
|
|
||||||
updateBoardSlug.mutate({
|
updateBoardSlug.mutate({
|
||||||
slug: data.slug,
|
slug: data.slug,
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
@@ -106,14 +130,23 @@ export function UpdateBoardSlugForm({
|
|||||||
<Input
|
<Input
|
||||||
id="board-slug"
|
id="board-slug"
|
||||||
{...register("slug")}
|
{...register("slug")}
|
||||||
errorMessage={errors.slug?.message}
|
errorMessage={errors.slug?.message || (isBoardSlugAvailable?.isReserved
|
||||||
prefix={`kan.bn/${workspaceSlug}/`}
|
? "This board URL has already been taken"
|
||||||
|
: undefined)}
|
||||||
|
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
|
||||||
onKeyDown={async (e) => {
|
onKeyDown={async (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await handleSubmit(onSubmit)();
|
await handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
iconRight={
|
||||||
|
!!errors.slug?.message || isBoardSlugAvailable?.isReserved ? (
|
||||||
|
<HiXMark className="h-4 w-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<HiCheck className="h-4 w-4 dark:text-dark-1000" />
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
@@ -124,7 +157,8 @@ export function UpdateBoardSlugForm({
|
|||||||
disabled={
|
disabled={
|
||||||
!isDirty ||
|
!isDirty ||
|
||||||
updateBoardSlug.isPending ||
|
updateBoardSlug.isPending ||
|
||||||
errors.slug?.message !== undefined
|
errors.slug?.message !== undefined ||
|
||||||
|
isBoardSlugAvailable?.isReserved
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Update
|
Update
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
|
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ const VisibilityButton = ({
|
|||||||
}: {
|
}: {
|
||||||
visibility: "public" | "private";
|
visibility: "public" | "private";
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
|
boardSlug: string;
|
||||||
queryParams: QueryParams;
|
queryParams: QueryParams;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -38,7 +40,11 @@ const VisibilityButton = ({
|
|||||||
|
|
||||||
const updateBoardVisibility = api.board.update.useMutation({
|
const updateBoardVisibility = api.board.update.useMutation({
|
||||||
onSuccess: () => {
|
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: () => {
|
onError: () => {
|
||||||
showPopup({
|
showPopup({
|
||||||
@@ -52,23 +58,39 @@ const VisibilityButton = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleUpdateBoardVisibility = () => {
|
|
||||||
updateBoardVisibility.mutate({
|
|
||||||
visibility: isPublic ? "private" : "public",
|
|
||||||
boardPublicId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<div className="relative">
|
||||||
variant="secondary"
|
<CheckboxDropdown
|
||||||
onClick={handleUpdateBoardVisibility}
|
items={[
|
||||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
{
|
||||||
isLoading={updateBoardVisibility.isPending}
|
key: "public",
|
||||||
disabled={isLoading || !isAdmin}
|
value: "Public",
|
||||||
>
|
selected: isPublic,
|
||||||
{isPublic ? "Public" : "Private"}
|
},
|
||||||
</Button>
|
{
|
||||||
|
key: "private",
|
||||||
|
value: "Private",
|
||||||
|
selected: !isPublic,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
handleSelect={(_g, i) => {
|
||||||
|
setStateVisibility(isPublic ? "private" : "public");
|
||||||
|
updateBoardVisibility.mutate({
|
||||||
|
visibility: i.key as "public" | "private",
|
||||||
|
boardPublicId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
menuSpacing="md"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||||
|
disabled={isLoading || !isAdmin}
|
||||||
|
>
|
||||||
|
Visibility
|
||||||
|
</Button>
|
||||||
|
</CheckboxDropdown>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -240,11 +240,12 @@ export default function BoardPage() {
|
|||||||
<div className="relative flex h-full flex-col">
|
<div className="relative flex h-full flex-col">
|
||||||
<PatternedBackground />
|
<PatternedBackground />
|
||||||
<div className="z-10 flex w-full justify-between p-8">
|
<div className="z-10 flex w-full justify-between p-8">
|
||||||
{isLoading ? (
|
{isLoading && !boardData && (
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
|
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
|
{boardData && (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
className="focus-visible:outline-none"
|
className="focus-visible:outline-none"
|
||||||
@@ -258,20 +259,26 @@ export default function BoardPage() {
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
{!boardData && !isLoading && (
|
||||||
|
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||||
|
Board not found
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<VisibilityButton
|
<VisibilityButton
|
||||||
visibility={boardData?.visibility ?? "private"}
|
visibility={boardData?.visibility ?? "private"}
|
||||||
boardPublicId={boardId ?? ""}
|
boardPublicId={boardId ?? ""}
|
||||||
|
boardSlug={boardData?.slug ?? ""}
|
||||||
queryParams={queryParams}
|
queryParams={queryParams}
|
||||||
isLoading={isLoading}
|
isLoading={!boardData}
|
||||||
isAdmin={workspace.role === "admin"}
|
isAdmin={workspace.role === "admin"}
|
||||||
/>
|
/>
|
||||||
<Filters
|
<Filters
|
||||||
labels={boardData?.labels ?? []}
|
labels={boardData?.labels ?? []}
|
||||||
members={boardData?.workspace.members ?? []}
|
members={boardData?.workspace.members ?? []}
|
||||||
position="left"
|
position="left"
|
||||||
isLoading={isLoading}
|
isLoading={!boardData}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
iconLeft={
|
iconLeft={
|
||||||
@@ -283,11 +290,11 @@ export default function BoardPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (boardId) openNewListForm(boardId);
|
if (boardId) openNewListForm(boardId);
|
||||||
}}
|
}}
|
||||||
disabled={isLoading}
|
disabled={!boardData}
|
||||||
>
|
>
|
||||||
New list
|
New list
|
||||||
</Button>
|
</Button>
|
||||||
<BoardDropdown isLoading={isLoading} />
|
<BoardDropdown isLoading={!boardData} />
|
||||||
</div>
|
</div>
|
||||||
</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-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||||
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : boardData ? (
|
||||||
<>
|
<>
|
||||||
{boardData?.lists.length === 0 ? (
|
{boardData?.lists.length === 0 ? (
|
||||||
<div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]">
|
<div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]">
|
||||||
@@ -350,7 +357,7 @@ export default function BoardPage() {
|
|||||||
<div
|
<div
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.droppableProps}
|
{...provided.droppableProps}
|
||||||
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar 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) => (
|
{list.cards.map((card, index) => (
|
||||||
<Draggable
|
<Draggable
|
||||||
@@ -404,7 +411,7 @@ export default function BoardPage() {
|
|||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
|
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
|
||||||
{modalContentType === "DELETE_BOARD" && (
|
{modalContentType === "DELETE_BOARD" && (
|
||||||
|
|||||||
@@ -1,37 +1,68 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Listbox, Transition } from "@headlessui/react";
|
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 { Controller, useForm } from "react-hook-form";
|
||||||
import { FaTrello } from "react-icons/fa";
|
import { FaTrello } from "react-icons/fa";
|
||||||
import {
|
import {
|
||||||
HiChevronUpDown,
|
HiChevronUpDown,
|
||||||
|
HiMiniArrowTopRightOnSquare,
|
||||||
HiOutlineQuestionMarkCircle,
|
HiOutlineQuestionMarkCircle,
|
||||||
HiXMark,
|
HiXMark,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Toggle from "~/components/Toggle";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
interface TrelloFormValues {
|
const integrationProviders: Record<
|
||||||
apiKey: string;
|
string,
|
||||||
token: string;
|
{ name: string; icon: JSX.Element }
|
||||||
}
|
> = {
|
||||||
|
trello: {
|
||||||
const sources = [{ source: "Trello" }];
|
name: "Trello",
|
||||||
|
icon: <FaTrello />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||||
|
const { data: integrations, refetch: refetchIntegrations } =
|
||||||
|
api.integration.providers.useQuery();
|
||||||
const { control, handleSubmit } = useForm({
|
const { control, handleSubmit } = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
source: "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 = () => {
|
const onSubmit = () => {
|
||||||
handleNextStep();
|
if (!hasIntegrations && trelloUrl) {
|
||||||
|
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
|
||||||
|
} else {
|
||||||
|
handleNextStep();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,9 +78,9 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Listbox.Button className="focus-ring-light-700 block w-full rounded-md border-0 bg-dark-300 bg-white/5 px-4 py-1.5 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6">
|
<Listbox.Button className="focus-ring-light-700 block w-full rounded-md border-0 bg-dark-300 bg-white/5 px-4 py-1.5 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6">
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<FaTrello />
|
{integrationProviders[field.value]?.icon}
|
||||||
<span className="ml-2 block truncate">
|
<span className="ml-2 block truncate">
|
||||||
{field.value}
|
{integrationProviders[field.value]?.name}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
@@ -68,20 +99,41 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
|||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-1 text-base text-neutral-900 shadow-lg ring-1 ring-light-600 ring-opacity-5 focus:outline-none dark:bg-dark-300 dark:text-dark-1000 sm:text-sm">
|
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-1 text-base text-neutral-900 shadow-lg ring-1 ring-light-600 ring-opacity-5 focus:outline-none dark:bg-dark-300 dark:text-dark-1000 sm:text-sm">
|
||||||
{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
|
<Listbox.Option
|
||||||
key={`source_${index}`}
|
key="trello_placeholder"
|
||||||
className="relative cursor-default select-none px-1"
|
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">
|
<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">
|
<span className="ml-2 block truncate font-normal">
|
||||||
{source}
|
{integrationProviders.trello?.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Listbox.Option>
|
</Listbox.Option>
|
||||||
))}
|
)}
|
||||||
</Listbox.Options>
|
</Listbox.Options>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</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 className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
<div>
|
<div>
|
||||||
<Button type="submit">Select source</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
iconRight={
|
||||||
|
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasIntegrations ? "Select source" : "Connect Trello"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -103,25 +162,26 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
|||||||
|
|
||||||
const ImportTrello: React.FC = () => {
|
const ImportTrello: React.FC = () => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [apiKey, setApiKey] = useState("");
|
|
||||||
const [token, setToken] = useState("");
|
|
||||||
const { closeModal } = useModal();
|
const { closeModal } = useModal();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
|
const [isSelectAllEnabled, setIsSelectAllEnabled] = useState(false);
|
||||||
|
|
||||||
const refetchBoards = () => utils.board.all.refetch();
|
const refetchBoards = () => utils.board.all.refetch();
|
||||||
|
|
||||||
const boards = api.import.trello.getBoards.useQuery(
|
const { data: boards, isLoading: boardsLoading } =
|
||||||
{ apiKey, token },
|
api.import.trello.getBoards.useQuery();
|
||||||
{
|
|
||||||
enabled: apiKey && token ? true : false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSetAuthDetails = (apiKey: string, token: string) => {
|
const {
|
||||||
setApiKey(apiKey);
|
register: registerBoards,
|
||||||
setToken(token);
|
handleSubmit: handleSubmitBoards,
|
||||||
};
|
setValue,
|
||||||
|
watch,
|
||||||
|
} = useForm({
|
||||||
|
defaultValues: Object.fromEntries(
|
||||||
|
boards?.map((board) => [board.id, true]) ?? [],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const importBoards = api.import.trello.importBoards.useMutation({
|
const importBoards = api.import.trello.importBoards.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -146,83 +206,94 @@ const ImportTrello: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register, handleSubmit } = useForm<TrelloFormValues>({
|
const boardWatchers = boards?.map((board) => ({
|
||||||
defaultValues: {
|
id: board.id,
|
||||||
apiKey: "",
|
value: watch(board.id),
|
||||||
token: "",
|
}));
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = (values: TrelloFormValues) => {
|
|
||||||
handleSetAuthDetails(values.apiKey, values.token);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { register: registerBoards, handleSubmit: handleSubmitBoards } =
|
|
||||||
useForm({
|
|
||||||
defaultValues: Object.fromEntries(
|
|
||||||
boards.data?.map((board) => [board.id, true]) ?? [],
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmitBoards = (values: Record<string, boolean>) => {
|
const onSubmitBoards = (values: Record<string, boolean>) => {
|
||||||
const boardIds = Object.keys(values).filter((key) => values[key] === true);
|
const boardIds = Object.keys(values).filter((key) => values[key] === true);
|
||||||
|
|
||||||
importBoards.mutate({
|
importBoards.mutate({
|
||||||
boardIds,
|
boardIds,
|
||||||
apiKey,
|
|
||||||
token,
|
|
||||||
workspacePublicId: workspace.publicId,
|
workspacePublicId: workspace.publicId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (boards.data?.length)
|
const renderContent = () => {
|
||||||
return (
|
if (boardsLoading) {
|
||||||
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
return (
|
||||||
<div className="h-[105px] overflow-scroll px-5">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-1">
|
||||||
{boards.data.map((board) => (
|
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||||
<div key={board.id}>
|
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||||
<label
|
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||||
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
|
|
||||||
htmlFor={board.id}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id={board.id}
|
|
||||||
type="checkbox"
|
|
||||||
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
|
|
||||||
{...registerBoards(board.id)}
|
|
||||||
/>
|
|
||||||
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
|
|
||||||
{board.name}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
if (!boards?.length) {
|
||||||
<div>
|
return (
|
||||||
<Button type="submit" isLoading={importBoards.isPending}>
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
Import boards
|
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||||
</Button>
|
No boards found
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
return boards.map((board) => (
|
||||||
|
<div key={board.id}>
|
||||||
|
<label
|
||||||
|
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
|
||||||
|
htmlFor={board.id}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={board.id}
|
||||||
|
type="checkbox"
|
||||||
|
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
|
||||||
|
{...registerBoards(board.id)}
|
||||||
|
/>
|
||||||
|
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
|
||||||
|
{board.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
<div className="h-[105px] overflow-scroll px-5">{renderContent()}</div>
|
||||||
className="text-neutral-900 dark:text-dark-1000"
|
|
||||||
>
|
|
||||||
<div className="space-y-4 px-5">
|
|
||||||
<Input id="apiKey" placeholder="API key" {...register("apiKey")} />
|
|
||||||
<Input id="token" placeholder="Token" {...register("token")} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
<div>
|
<Toggle
|
||||||
<Button type="submit" isLoading={boards.isLoading}>
|
label="Select all"
|
||||||
Fetch boards
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
HiOutlineUserMinus,
|
HiOutlineUserMinus,
|
||||||
HiOutlineUserPlus,
|
HiOutlineUserPlus,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import type { GetCardByIdOutput } from "@kan/api/types";
|
import type { GetCardByIdOutput } from "@kan/api/types";
|
||||||
|
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Comment from "./Comment";
|
import Comment from "./Comment";
|
||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
type ActivityType =
|
type ActivityType =
|
||||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||||
@@ -147,6 +147,8 @@ const ActivityList = ({
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const { data } = authClient.useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-4 pt-4">
|
<div className="flex flex-col space-y-4 pt-4">
|
||||||
{activities.map((activity, index) => {
|
{activities.map((activity, index) => {
|
||||||
@@ -156,7 +158,7 @@ const ActivityList = ({
|
|||||||
fromList: activity.fromList?.name ?? null,
|
fromList: activity.fromList?.name ?? null,
|
||||||
toList: activity.toList?.name ?? null,
|
toList: activity.toList?.name ?? null,
|
||||||
memberName: activity.member?.user?.name ?? null,
|
memberName: activity.member?.user?.name ?? null,
|
||||||
isSelf: activity.member?.user?.id === activity.user?.id,
|
isSelf: activity.member?.user?.id === data?.user?.id,
|
||||||
label: activity.label?.name ?? null,
|
label: activity.label?.name ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,7 +174,7 @@ const ActivityList = ({
|
|||||||
createdAt={activity.createdAt}
|
createdAt={activity.createdAt}
|
||||||
comment={activity.comment?.comment}
|
comment={activity.comment?.comment}
|
||||||
isEdited={!!activity.comment?.updatedAt}
|
isEdited={!!activity.comment?.updatedAt}
|
||||||
isAuthor={activity.comment?.createdBy === activity.user?.id}
|
isAuthor={activity.comment?.createdBy === data?.user?.id}
|
||||||
isAdmin={isAdmin ?? false}
|
isAdmin={isAdmin ?? false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -132,7 +132,11 @@ const Comment = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<p className="mt-2 text-sm">{comment}</p>
|
<ContentEditable
|
||||||
|
html={comment ?? ""}
|
||||||
|
disabled={true}
|
||||||
|
className="mt-2 text-sm"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import ContentEditable from "react-contenteditable";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { IoChevronForwardSharp } from "react-icons/io5";
|
import { IoChevronForwardSharp } from "react-icons/io5";
|
||||||
|
|
||||||
@@ -24,6 +23,7 @@ import LabelSelector from "./components/LabelSelector";
|
|||||||
import ListSelector from "./components/ListSelector";
|
import ListSelector from "./components/ListSelector";
|
||||||
import MemberSelector from "./components/MemberSelector";
|
import MemberSelector from "./components/MemberSelector";
|
||||||
import NewCommentForm from "./components/NewCommentForm";
|
import NewCommentForm from "./components/NewCommentForm";
|
||||||
|
import Editor from "~/components/Editor";
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
@@ -137,6 +137,8 @@ export default function CardPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const description = watch("description");
|
||||||
|
|
||||||
if (!cardId) return <></>;
|
if (!cardId) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -148,12 +150,13 @@ export default function CardPage() {
|
|||||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||||
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8">
|
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8">
|
||||||
<div className="mb-8 flex w-full items-center justify-between">
|
<div className="mb-8 flex w-full items-center justify-between">
|
||||||
{isLoading ? (
|
{!card && isLoading && (
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
|
{card && (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
|
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
|
||||||
@@ -184,40 +187,46 @@ export default function CardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!card && !isLoading && (
|
||||||
|
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||||
|
Card not found
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
{card && (
|
||||||
<form
|
<>
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||||
className="w-full space-y-6"
|
<form
|
||||||
>
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
<div className="mt-2">
|
className="w-full space-y-6"
|
||||||
<ContentEditable
|
>
|
||||||
placeholder="Add description..."
|
<div className="mt-2">
|
||||||
html={watch("description")}
|
<Editor
|
||||||
disabled={false}
|
content={card.description}
|
||||||
onChange={(e) => setValue("description", e.target.value)}
|
onChange={(e) => setValue("description", e)}
|
||||||
onBlur={handleSubmit(onSubmit)}
|
onBlur={() => handleSubmit(onSubmit)()}
|
||||||
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
/>
|
||||||
/>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
|
||||||
</div>
|
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||||
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
|
Activity
|
||||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
</h2>
|
||||||
Activity
|
<div>
|
||||||
</h2>
|
<ActivityList
|
||||||
<div>
|
cardPublicId={cardId}
|
||||||
<ActivityList
|
activities={activities ?? []}
|
||||||
cardPublicId={cardId}
|
isLoading={!card}
|
||||||
activities={activities ?? []}
|
isAdmin={workspace.role === "admin"}
|
||||||
isLoading={isLoading}
|
/>
|
||||||
isAdmin={workspace.role === "admin"}
|
</div>
|
||||||
/>
|
<div className="mt-6">
|
||||||
</div>
|
<NewCommentForm cardPublicId={cardId} />
|
||||||
<div className="mt-6">
|
</div>
|
||||||
<NewCommentForm cardPublicId={cardId} />
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[475px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
|
<div className="w-[475px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
|
||||||
@@ -226,7 +235,7 @@ export default function CardPage() {
|
|||||||
<ListSelector
|
<ListSelector
|
||||||
cardPublicId={cardId}
|
cardPublicId={cardId}
|
||||||
lists={formattedLists}
|
lists={formattedLists}
|
||||||
isLoading={isLoading}
|
isLoading={!card}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 flex w-full">
|
<div className="mb-4 flex w-full">
|
||||||
@@ -234,7 +243,7 @@ export default function CardPage() {
|
|||||||
<LabelSelector
|
<LabelSelector
|
||||||
cardPublicId={cardId}
|
cardPublicId={cardId}
|
||||||
labels={formattedLabels}
|
labels={formattedLabels}
|
||||||
isLoading={isLoading}
|
isLoading={!card}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full">
|
<div className="flex w-full">
|
||||||
@@ -242,7 +251,7 @@ export default function CardPage() {
|
|||||||
<MemberSelector
|
<MemberSelector
|
||||||
cardPublicId={cardId}
|
cardPublicId={cardId}
|
||||||
members={formattedMembers}
|
members={formattedMembers}
|
||||||
isLoading={isLoading}
|
isLoading={!card}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import Cookies from "js-cookie";
|
|
||||||
|
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
import { useTheme } from "~/providers/theme";
|
import { useTheme } from "~/providers/theme";
|
||||||
import { api } from "~/utils/api";
|
|
||||||
import Footer from "./Footer";
|
import Footer from "./Footer";
|
||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const token =
|
const { data: session } = authClient.useSession();
|
||||||
typeof window !== "undefined" ? Cookies.get("kan.session_token") : null;
|
|
||||||
|
|
||||||
const { data } = api.user.getUser.useQuery(undefined, {
|
const isLoggedIn = !!session?.user;
|
||||||
enabled: !!token,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isLoggedIn = !!data;
|
|
||||||
|
|
||||||
const isDarkMode = theme.activeTheme === "dark";
|
const isDarkMode = theme.activeTheme === "dark";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { IoLogoGithub } from "react-icons/io";
|
import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
@@ -21,18 +21,41 @@ export default function HomeView() {
|
|||||||
<div className="flex h-full w-full flex-col lg:pt-[5rem]">
|
<div className="flex h-full w-full flex-col lg:pt-[5rem]">
|
||||||
<div className="w-full pb-10 pt-32 lg:py-32">
|
<div className="w-full pb-10 pt-32 lg:py-32">
|
||||||
<div className="my-10 flex h-full w-full animate-fade-down flex-col items-center justify-center px-4">
|
<div className="my-10 flex h-full w-full animate-fade-down flex-col items-center justify-center px-4">
|
||||||
<div className="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="flex items-center gap-2">
|
||||||
<div className="gradient-border absolute inset-0 animate-border-spin" />
|
<div className="relative animate-fade-in overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
|
||||||
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
<div className="gradient-border absolute inset-0 animate-border-spin" />
|
||||||
<Link
|
|
||||||
href="https://github.com/kanbn/kan"
|
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
||||||
rel="noopener noreferrer"
|
<Link
|
||||||
target="_blank"
|
href="https://github.com/kanbn/kan"
|
||||||
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
|
rel="noopener noreferrer"
|
||||||
>
|
target="_blank"
|
||||||
Star on Github
|
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
|
||||||
<IoLogoGithub size={20} />
|
>
|
||||||
</Link>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { HiXMark } from "react-icons/hi2";
|
import { HiXMark } from "react-icons/hi2";
|
||||||
|
import Editor from "~/components/Editor";
|
||||||
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -16,6 +18,7 @@ export function CardModal({
|
|||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { closeModal, isOpen } = useModal();
|
const { closeModal, isOpen } = useModal();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
const { data, isLoading } = api.card.byId.useQuery(
|
const { data, isLoading } = api.card.byId.useQuery(
|
||||||
{
|
{
|
||||||
@@ -90,9 +93,7 @@ export function CardModal({
|
|||||||
{data?.description && (
|
{data?.description && (
|
||||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<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">
|
<Editor content={data.description} readOnly />
|
||||||
{data.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export default function PublicBoardView() {
|
|||||||
{list.name}
|
{list.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar 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) => (
|
{list.cards.map((card) => (
|
||||||
<Link
|
<Link
|
||||||
key={card.publicId}
|
key={card.publicId}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { authClient } from "@kan/auth";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
|
|||||||
@@ -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 [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const deleteWorkspaceMutation = api.workspace.delete.useMutation({
|
const deleteWorkspaceMutation = api.workspace.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
showPopup({
|
showPopup({
|
||||||
header: "Workspace deleted",
|
header: "Workspace deleted",
|
||||||
message: "Your workspace has been deleted.",
|
message: "Your workspace has been deleted.",
|
||||||
icon: "success",
|
icon: "success",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await utils.workspace.all.refetch();
|
||||||
|
|
||||||
const filteredWorkspaces = availableWorkspaces.filter(
|
const filteredWorkspaces = availableWorkspaces.filter(
|
||||||
(ws) => ws.publicId !== workspace.publicId,
|
(ws) => ws.publicId !== workspace.publicId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { HiCheck, HiMiniStar } from "react-icons/hi2";
|
import { HiCheck, HiMiniStar } from "react-icons/hi2";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -92,7 +93,9 @@ const UpdateWorkspaceUrlForm = ({
|
|||||||
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
|
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
|
||||||
|
|
||||||
const onSubmit = (data: FormValues) => {
|
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);
|
return openModal("UPDATE_WORKSPACE_URL", data.slug);
|
||||||
|
|
||||||
updateWorkspaceSlug.mutate({
|
updateWorkspaceSlug.mutate({
|
||||||
@@ -118,7 +121,11 @@ const UpdateWorkspaceUrlForm = ({
|
|||||||
? "This workspace username has already been taken"
|
? "This workspace username has already been taken"
|
||||||
: undefined)
|
: undefined)
|
||||||
}
|
}
|
||||||
prefix="kan.bn/"
|
prefix={
|
||||||
|
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||||
|
? "kan.bn/"
|
||||||
|
: `${env("NEXT_PUBLIC_BASE_URL")}/`
|
||||||
|
}
|
||||||
iconRight={
|
iconRight={
|
||||||
isWorkspaceSlugAvailable?.isAvailable ||
|
isWorkspaceSlugAvailable?.isAvailable ||
|
||||||
(workspacePlan === "pro" && slug === workspaceUrl) ? (
|
(workspacePlan === "pro" && slug === workspaceUrl) ? (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
@@ -6,11 +7,13 @@ import Modal from "~/components/modal";
|
|||||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import Avatar from "./components/Avatar";
|
import Avatar from "./components/Avatar";
|
||||||
import CreateAPIKeyForm from "./components/CreateAPIKeyForm";
|
import CreateAPIKeyForm from "./components/CreateAPIKeyForm";
|
||||||
import { CustomURLConfirmation } from "./components/CustomURLConfirmation";
|
import { CustomURLConfirmation } from "./components/CustomURLConfirmation";
|
||||||
|
import { DeleteAccountConfirmation } from "./components/DeleteAccountConfirmation";
|
||||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||||
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
|
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
|
||||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||||
@@ -21,9 +24,54 @@ export default function SettingsPage() {
|
|||||||
const { modalContentType, openModal } = useModal();
|
const { modalContentType, openModal } = useModal();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
const { data } = api.user.getUser.useQuery();
|
const { data } = api.user.getUser.useQuery();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: integrations,
|
||||||
|
refetch: refetchIntegrations,
|
||||||
|
isLoading: integrationsLoading,
|
||||||
|
} = api.integration.providers.useQuery();
|
||||||
|
|
||||||
|
const { data: trelloUrl, refetch: refetchTrelloUrl } =
|
||||||
|
api.integration.getAuthorizationUrl.useQuery({ provider: "trello" }, {
|
||||||
|
enabled:
|
||||||
|
!integrationsLoading &&
|
||||||
|
!integrations?.some((integration) => integration.provider === "trello"),
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFocus = () => {
|
||||||
|
refetchIntegrations();
|
||||||
|
};
|
||||||
|
window.addEventListener("focus", handleFocus);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("focus", handleFocus);
|
||||||
|
};
|
||||||
|
}, [refetchIntegrations]);
|
||||||
|
|
||||||
|
const { mutateAsync: disconnectTrello } = api.integration.disconnect.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchUser();
|
||||||
|
refetchIntegrations();
|
||||||
|
refetchTrelloUrl();
|
||||||
|
showPopup({
|
||||||
|
header: "Trello disconnected",
|
||||||
|
message: "Your Trello account has been disconnected.",
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: "Error disconnecting Trello",
|
||||||
|
message: "An error occurred while disconnecting your Trello account.",
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const refetchUser = () => utils.user.getUser.refetch();
|
const refetchUser = () => utils.user.getUser.refetch();
|
||||||
|
|
||||||
const handleOpenBillingPortal = async () => {
|
const handleOpenBillingPortal = async () => {
|
||||||
@@ -114,6 +162,48 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||||
|
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||||
|
Trello
|
||||||
|
</h2>
|
||||||
|
{!integrations?.some(
|
||||||
|
(integration) => integration.provider === "trello",
|
||||||
|
) && trelloUrl ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
|
Connect your Trello account to import boards.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
iconRight={<HiMiniArrowTopRightOnSquare />}
|
||||||
|
onClick={() =>
|
||||||
|
window.open(
|
||||||
|
trelloUrl.url,
|
||||||
|
"trello_auth",
|
||||||
|
"height=800,width=600",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Connect Trello
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
|
You are already connected to Trello.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
disconnectTrello({ provider: "trello" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Disconnect Trello
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||||
API keys
|
API keys
|
||||||
@@ -127,20 +217,40 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-light-300 dark:border-dark-300">
|
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||||
<h2 className="mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||||
Delete workspace
|
Delete workspace
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-8 mt-2 text-sm text-neutral-500 dark:text-dark-900">
|
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
Once you delete your workspace, there is no going back. Please
|
Once you delete your workspace, there is no going back. This
|
||||||
be certain.
|
action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<div className="mt-4">
|
||||||
variant="primary"
|
<Button
|
||||||
onClick={() => openModal("DELETE_WORKSPACE")}
|
variant="secondary"
|
||||||
>
|
onClick={() => openModal("DELETE_WORKSPACE")}
|
||||||
Delete workspace
|
>
|
||||||
</Button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -152,6 +262,9 @@ export default function SettingsPage() {
|
|||||||
{modalContentType === "UPDATE_WORKSPACE_URL" && (
|
{modalContentType === "UPDATE_WORKSPACE_URL" && (
|
||||||
<CustomURLConfirmation workspacePublicId={workspace.publicId} />
|
<CustomURLConfirmation workspacePublicId={workspace.publicId} />
|
||||||
)}
|
)}
|
||||||
|
{modalContentType === "DELETE_ACCOUNT" && (
|
||||||
|
<DeleteAccountConfirmation />
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import baseConfig from "@kan/tailwind-config/web";
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: [...baseConfig.content],
|
content: [...baseConfig.content],
|
||||||
|
plugins: [require("@tailwindcss/typography")],
|
||||||
presets: [baseConfig],
|
presets: [baseConfig],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
63
cloud/docker-compose.yml
Normal file
63
cloud/docker-compose.yml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: ghcr.io/kanbn/kan:latest
|
||||||
|
container_name: ${CONTAINER_NAME:-kan-web}
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-3000}:3000"
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/web/Dockerfile
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
|
||||||
|
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||||
|
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||||
|
- POSTGRES_URL=${POSTGRES_URL}
|
||||||
|
- NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT}
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||||
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||||
|
|
||||||
|
# SMTP
|
||||||
|
- SMTP_HOST=${SMTP_HOST}
|
||||||
|
- SMTP_PORT=${SMTP_PORT}
|
||||||
|
- SMTP_USER=${SMTP_USER}
|
||||||
|
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||||
|
- EMAIL_FROM=${EMAIL_FROM}
|
||||||
|
|
||||||
|
# S3 storage
|
||||||
|
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||||
|
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||||
|
- S3_REGION=${S3_REGION}
|
||||||
|
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||||
|
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||||
|
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||||
|
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||||
|
|
||||||
|
# Auth config
|
||||||
|
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
|
||||||
|
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
|
||||||
|
|
||||||
|
# Integration providers
|
||||||
|
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
|
||||||
|
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
|
||||||
|
|
||||||
|
# OAuth providers (optional)
|
||||||
|
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
||||||
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||||
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||||
|
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||||
|
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
|
#bump
|
||||||
@@ -1,40 +1,117 @@
|
|||||||
version: "3.7"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: ghcr.io/kanbn/kan:latest
|
image: ghcr.io/kanbn/kan:latest
|
||||||
|
container_name: ${CONTAINER_NAME:-kan-web}
|
||||||
ports:
|
ports:
|
||||||
- "3001:3000"
|
- "${WEB_PORT:-3000}:3000"
|
||||||
networks:
|
networks:
|
||||||
|
- kan-network
|
||||||
- dokploy-network
|
- dokploy-network
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./apps/web/Dockerfile
|
dockerfile: ./apps/web/Dockerfile.new
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
command: ["pnpm", "start"]
|
|
||||||
environment:
|
environment:
|
||||||
- EMAIL_FROM=${EMAIL_FROM}
|
# Required environment variables
|
||||||
- EMAIL_URL=${EMAIL_URL}
|
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||||
- EMAIL_TOKEN=${EMAIL_TOKEN}
|
|
||||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
- 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}
|
- POSTGRES_URL=${POSTGRES_URL}
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
# SMTP (optional)
|
||||||
|
- SMTP_HOST=${SMTP_HOST}
|
||||||
|
- SMTP_PORT=${SMTP_PORT}
|
||||||
|
- SMTP_USER=${SMTP_USER}
|
||||||
|
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||||
|
- EMAIL_FROM=${EMAIL_FROM}
|
||||||
|
|
||||||
|
# S3 storage (optional)
|
||||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||||
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||||
- S3_REGION=${S3_REGION}
|
- S3_REGION=${S3_REGION}
|
||||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||||
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
|
|
||||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
|
||||||
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||||
|
|
||||||
|
# Auth config (optional)
|
||||||
|
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
|
||||||
|
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
|
||||||
|
|
||||||
|
# Integration providers (optional)
|
||||||
|
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
|
||||||
|
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
|
||||||
|
|
||||||
|
# OAuth providers (optional)
|
||||||
|
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
||||||
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||||
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||||
|
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||||
|
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||||
|
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
||||||
|
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
|
||||||
|
- GITLAB_CLIENT_ID=${GITLAB_CLIENT_ID}
|
||||||
|
- GITLAB_CLIENT_SECRET=${GITLAB_CLIENT_SECRET}
|
||||||
|
- GITLAB_ISSUER=${GITLAB_ISSUER}
|
||||||
|
- MICROSOFT_CLIENT_ID=${MICROSOFT_CLIENT_ID}
|
||||||
|
- MICROSOFT_CLIENT_SECRET=${MICROSOFT_CLIENT_SECRET}
|
||||||
|
- TWITTER_CLIENT_ID=${TWITTER_CLIENT_ID}
|
||||||
|
- TWITTER_CLIENT_SECRET=${TWITTER_CLIENT_SECRET}
|
||||||
|
- KICK_CLIENT_ID=${KICK_CLIENT_ID}
|
||||||
|
- KICK_CLIENT_SECRET=${KICK_CLIENT_SECRET}
|
||||||
|
- ZOOM_CLIENT_ID=${ZOOM_CLIENT_ID}
|
||||||
|
- ZOOM_CLIENT_SECRET=${ZOOM_CLIENT_SECRET}
|
||||||
|
- DROPBOX_CLIENT_ID=${DROPBOX_CLIENT_ID}
|
||||||
|
- DROPBOX_CLIENT_SECRET=${DROPBOX_CLIENT_SECRET}
|
||||||
|
- VK_CLIENT_ID=${VK_CLIENT_ID}
|
||||||
|
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
|
||||||
|
- LINKEDIN_CLIENT_ID=${LINKEDIN_CLIENT_ID}
|
||||||
|
- LINKEDIN_CLIENT_SECRET=${LINKEDIN_CLIENT_SECRET}
|
||||||
|
- REDDIT_CLIENT_ID=${REDDIT_CLIENT_ID}
|
||||||
|
- REDDIT_CLIENT_SECRET=${REDDIT_CLIENT_SECRET}
|
||||||
|
- ROBLOX_CLIENT_ID=${ROBLOX_CLIENT_ID}
|
||||||
|
- ROBLOX_CLIENT_SECRET=${ROBLOX_CLIENT_SECRET}
|
||||||
|
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
|
||||||
|
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
|
||||||
|
- TIKTOK_CLIENT_ID=${TIKTOK_CLIENT_ID}
|
||||||
|
- TIKTOK_CLIENT_SECRET=${TIKTOK_CLIENT_SECRET}
|
||||||
|
- TIKTOK_CLIENT_KEY=${TIKTOK_CLIENT_KEY}
|
||||||
|
- TWITCH_CLIENT_ID=${TWITCH_CLIENT_ID}
|
||||||
|
- TWITCH_CLIENT_SECRET=${TWITCH_CLIENT_SECRET}
|
||||||
|
- APPLE_CLIENT_ID=${APPLE_CLIENT_ID}
|
||||||
|
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
|
||||||
|
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
|
||||||
|
|
||||||
|
# Cloud (not required)
|
||||||
|
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
|
||||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||||
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||||
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||||
|
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||||
|
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
container_name: kan-db
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=kan_db
|
||||||
|
- POSTGRES_USER=kan
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
volumes:
|
||||||
|
- kan_postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- kan-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
kan-network:
|
||||||
dokploy-network:
|
dokploy-network:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
kan_postgres_data:
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ export const openApiDocument = generateOpenApiDocument(appRouter, {
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
baseUrl: `${env("NEXT_PUBLIC_BASE_URL")}/api/v1`,
|
baseUrl: `${env("NEXT_PUBLIC_BASE_URL")}/api/v1`,
|
||||||
docsUrl: "docs.kan.bn",
|
docsUrl: "docs.kan.bn",
|
||||||
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports"],
|
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports", "Integrations"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { boardRouter } from "./routers/board";
|
|||||||
import { cardRouter } from "./routers/card";
|
import { cardRouter } from "./routers/card";
|
||||||
import { feedbackRouter } from "./routers/feedback";
|
import { feedbackRouter } from "./routers/feedback";
|
||||||
import { importRouter } from "./routers/import";
|
import { importRouter } from "./routers/import";
|
||||||
|
import { integrationRouter } from "./routers/integration";
|
||||||
import { labelRouter } from "./routers/label";
|
import { labelRouter } from "./routers/label";
|
||||||
import { listRouter } from "./routers/list";
|
import { listRouter } from "./routers/list";
|
||||||
import { memberRouter } from "./routers/member";
|
import { memberRouter } from "./routers/member";
|
||||||
@@ -19,6 +20,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
import: importRouter,
|
import: importRouter,
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
workspace: workspaceRouter,
|
workspace: workspaceRouter,
|
||||||
|
integration: integrationRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -352,4 +352,40 @@ export const boardRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
checkSlugAvailability: publicProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Check if a board slug is available",
|
||||||
|
method: "GET",
|
||||||
|
path: "/boards/{boardPublicId}/check-slug-availability",
|
||||||
|
description: "Checks if a board slug is available",
|
||||||
|
tags: ["Boards"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
boardSlug: z
|
||||||
|
.string()
|
||||||
|
.min(3)
|
||||||
|
.max(24)
|
||||||
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||||
|
boardPublicId: z.string().min(12),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
isReserved: z.boolean(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
||||||
|
ctx.db,
|
||||||
|
input.boardSlug,
|
||||||
|
input.boardPublicId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
isReserved: !isBoardSlugAvailable,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -578,14 +578,6 @@ export const cardRouter = createTRPCRouter({
|
|||||||
>(),
|
>(),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.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(
|
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
input.cardPublicId,
|
input.cardPublicId,
|
||||||
@@ -597,7 +589,17 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
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(
|
const result = await cardRepo.getWithListAndMembersByPublicId(
|
||||||
ctx.db,
|
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 cardRepo from "@kan/db/repository/card.repo";
|
||||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||||
import * as importRepo from "@kan/db/repository/import.repo";
|
import * as importRepo from "@kan/db/repository/import.repo";
|
||||||
|
import * as integrationsRepo from "@kan/db/repository/integration.repo";
|
||||||
import * as labelRepo from "@kan/db/repository/label.repo";
|
import * as labelRepo from "@kan/db/repository/label.repo";
|
||||||
import * as listRepo from "@kan/db/repository/list.repo";
|
import * as listRepo from "@kan/db/repository/list.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
@@ -13,10 +14,9 @@ import { generateUID } from "@kan/shared/utils";
|
|||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertUserInWorkspace } from "../utils/auth";
|
||||||
|
import { apiKeys, urls } from "./integration";
|
||||||
|
|
||||||
const TRELLO_API_URL = "https://api.trello.com/1";
|
export interface TrelloBoard {
|
||||||
|
|
||||||
interface TrelloBoard {
|
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
labels: TrelloLabel[];
|
labels: TrelloLabel[];
|
||||||
@@ -42,10 +42,6 @@ interface TrelloCard {
|
|||||||
labels: TrelloLabel[];
|
labels: TrelloLabel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MemberData {
|
|
||||||
idBoards: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const importRouter = createTRPCRouter({
|
export const importRouter = createTRPCRouter({
|
||||||
trello: createTRPCRouter({
|
trello: createTRPCRouter({
|
||||||
getBoards: protectedProcedure
|
getBoards: protectedProcedure
|
||||||
@@ -53,46 +49,51 @@ export const importRouter = createTRPCRouter({
|
|||||||
openapi: {
|
openapi: {
|
||||||
summary: "Get boards from Trello",
|
summary: "Get boards from Trello",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/imports/trello/boards",
|
path: "/integrations/trello/boards",
|
||||||
description: "Retrieves all boards from Trello",
|
description: "Retrieves all boards from Trello",
|
||||||
tags: ["Imports"],
|
tags: ["Integrations"],
|
||||||
protect: true,
|
protect: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
apiKey: z.string().length(32),
|
|
||||||
token: z.string().length(76),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(z.array(z.object({ id: z.string(), name: z.string() })))
|
.output(z.array(z.object({ id: z.string(), name: z.string() })))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ ctx }) => {
|
||||||
const fetchMemberRes = await fetch(
|
const apiKey = apiKeys.trello;
|
||||||
`${TRELLO_API_URL}/tokens/${input.token}/member?key=${input.apiKey}`,
|
|
||||||
|
if (!apiKey)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Trello API key not found",
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = ctx.user;
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
|
const integration = await integrationsRepo.getProviderForUser(
|
||||||
|
ctx.db,
|
||||||
|
user.id,
|
||||||
|
"trello",
|
||||||
);
|
);
|
||||||
|
|
||||||
const 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(
|
||||||
const response = await fetch(
|
`${urls.trello}/members/me/boards?key=${apiKey}&token=${token}`,
|
||||||
`${TRELLO_API_URL}/boards/${boardId}?key=${input.apiKey}&token=${input.token}`,
|
);
|
||||||
);
|
|
||||||
const data = (await response.json()) as TrelloBoard;
|
|
||||||
|
|
||||||
return data;
|
const data = (await response.json()) as TrelloBoard[];
|
||||||
};
|
|
||||||
|
|
||||||
const boards = [];
|
return data.map((board) => ({
|
||||||
|
|
||||||
for (const boardId of boardIds) {
|
|
||||||
boards.push(Promise.resolve(fetchBoard(boardId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
const boardDataArray = await Promise.all(boards);
|
|
||||||
|
|
||||||
return boardDataArray.map((board) => ({
|
|
||||||
id: board.id,
|
id: board.id,
|
||||||
name: board.name,
|
name: board.name,
|
||||||
}));
|
}));
|
||||||
@@ -102,7 +103,7 @@ export const importRouter = createTRPCRouter({
|
|||||||
openapi: {
|
openapi: {
|
||||||
summary: "Import boards from Trello",
|
summary: "Import boards from Trello",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/imports/trello/import",
|
path: "/imports/trello/boards",
|
||||||
description: "Imports boards from Trello",
|
description: "Imports boards from Trello",
|
||||||
tags: ["Imports"],
|
tags: ["Imports"],
|
||||||
protect: true,
|
protect: true,
|
||||||
@@ -111,8 +112,6 @@ export const importRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
boardIds: z.array(z.string()),
|
boardIds: z.array(z.string()),
|
||||||
apiKey: z.string().length(32),
|
|
||||||
token: z.string().length(76),
|
|
||||||
workspacePublicId: z.string().min(12),
|
workspacePublicId: z.string().min(12),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -120,12 +119,32 @@ export const importRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.user?.id;
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
const apiKey = apiKeys.trello;
|
||||||
|
|
||||||
|
if (!apiKey)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Trello API key not found",
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
});
|
||||||
|
|
||||||
if (!userId)
|
if (!userId)
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
message: `User not authenticated`,
|
message: `User not authenticated`,
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const integration = await integrationsRepo.getProviderForUser(
|
||||||
|
ctx.db,
|
||||||
|
userId,
|
||||||
|
"trello",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!integration)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Trello token not found",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
const workspace = await workspaceRepo.getByPublicId(
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
input.workspacePublicId,
|
input.workspacePublicId,
|
||||||
@@ -150,7 +169,7 @@ export const importRouter = createTRPCRouter({
|
|||||||
|
|
||||||
for (const boardId of input.boardIds) {
|
for (const boardId of input.boardIds) {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${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;
|
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 { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
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 memberRepo from "@kan/db/repository/member.repo";
|
||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
@@ -224,10 +225,19 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
const isWorkspaceSlugAvailable =
|
const isWorkspaceSlugAvailable =
|
||||||
await workspaceRepo.isWorkspaceSlugAvailable(ctx.db, input.slug);
|
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 (
|
if (
|
||||||
reservedOrPremiumWorkspaceSlug?.type === "reserved" ||
|
reservedOrPremiumWorkspaceSlug?.type === "reserved" ||
|
||||||
(workspace.plan !== "pro" &&
|
|
||||||
reservedOrPremiumWorkspaceSlug?.type === "premium") ||
|
|
||||||
!isWorkspaceSlugAvailable
|
!isWorkspaceSlugAvailable
|
||||||
) {
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import superjson from "superjson";
|
|||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import { initAuth } from "@kan/auth";
|
import { initAuth } from "@kan/auth/server";
|
||||||
import { createDrizzleClient } from "@kan/db/client";
|
import { createDrizzleClient } from "@kan/db/client";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
|||||||
@@ -4,7 +4,15 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"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",
|
"license": "GPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { createAuthMiddleware } from "better-auth/api";
|
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
||||||
import { apiKey } from "better-auth/plugins";
|
import { apiKey } from "better-auth/plugins";
|
||||||
import { magicLink } from "better-auth/plugins/magic-link";
|
import { magicLink } from "better-auth/plugins/magic-link";
|
||||||
|
import { socialProviderList } from "better-auth/social-providers";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
@@ -12,13 +14,99 @@ import * as schema from "@kan/db/schema";
|
|||||||
import { sendEmail } from "@kan/email";
|
import { sendEmail } from "@kan/email";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
|
||||||
|
export const configuredProviders = socialProviderList.reduce<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
appBundleIdentifier?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
requireSelectAccount?: boolean;
|
||||||
|
clientKey?: string;
|
||||||
|
issuer?: string;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>((acc, provider) => {
|
||||||
|
const id = process.env[`${provider.toUpperCase()}_CLIENT_ID`];
|
||||||
|
const secret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`];
|
||||||
|
if (id && id.length > 0 && secret && secret.length > 0) {
|
||||||
|
acc[provider] = { clientId: id, clientSecret: secret };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider === "apple" &&
|
||||||
|
Object.keys(acc).includes("apple") &&
|
||||||
|
acc[provider]
|
||||||
|
) {
|
||||||
|
const bundleId =
|
||||||
|
process.env[`${provider.toUpperCase()}_APP_BUNDLE_IDENTIFIER`];
|
||||||
|
if (bundleId && bundleId.length > 0) {
|
||||||
|
acc[provider].appBundleIdentifier = bundleId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider === "gitlab" &&
|
||||||
|
Object.keys(acc).includes("gitlab") &&
|
||||||
|
acc[provider]
|
||||||
|
) {
|
||||||
|
const issuer = process.env[`${provider.toUpperCase()}_ISSUER`];
|
||||||
|
if (issuer && issuer.length > 0) {
|
||||||
|
acc[provider].issuer = issuer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider === "microsoft" &&
|
||||||
|
Object.keys(acc).includes("microsoft") &&
|
||||||
|
acc[provider]
|
||||||
|
) {
|
||||||
|
acc[provider].tenantId = "common";
|
||||||
|
acc[provider].requireSelectAccount = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider === "tiktok" &&
|
||||||
|
Object.keys(acc).includes("tiktok") &&
|
||||||
|
acc[provider]
|
||||||
|
) {
|
||||||
|
const key = process.env[`${provider.toUpperCase()}_CLIENT_KEY`];
|
||||||
|
if (key && key.length > 0) {
|
||||||
|
acc[provider].clientKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
export const socialProvidersPlugin = () => ({
|
||||||
|
id: "social-providers-plugin",
|
||||||
|
endpoints: {
|
||||||
|
getSocialProviders: createAuthEndpoint(
|
||||||
|
"/social-providers",
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
async (ctx) =>
|
||||||
|
ctx.json(ctx.context.socialProviders.map((p) => p.name.toLowerCase())),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function downloadImage(url: string): Promise<Buffer> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download image: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
export const initAuth = (db: dbClient) => {
|
export const initAuth = (db: dbClient) => {
|
||||||
return betterAuth({
|
return betterAuth({
|
||||||
secret: process.env.BETTER_AUTH_SECRET!,
|
secret: process.env.BETTER_AUTH_SECRET!,
|
||||||
baseURL: process.env.BETTER_AUTH_BASE_URL!,
|
baseURL: process.env.NEXT_PUBLIC_BASE_URL!,
|
||||||
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||||
? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(",")
|
? [
|
||||||
: [],
|
process.env.NEXT_PUBLIC_BASE_URL!,
|
||||||
|
...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","),
|
||||||
|
]
|
||||||
|
: [process.env.NEXT_PUBLIC_BASE_URL!],
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
schema: {
|
schema: {
|
||||||
@@ -26,13 +114,22 @@ export const initAuth = (db: dbClient) => {
|
|||||||
user: schema.users,
|
user: schema.users,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
socialProviders: {
|
emailAndPassword: {
|
||||||
google: {
|
enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true",
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
disableSignUp:
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
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: {
|
user: {
|
||||||
|
deleteUser: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
additionalFields: {
|
additionalFields: {
|
||||||
stripeCustomerId: {
|
stripeCustomerId: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -43,6 +140,7 @@ export const initAuth = (db: dbClient) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
socialProvidersPlugin(),
|
||||||
// @todo: hasing is disabled due to a bug in the api key plugin
|
// @todo: hasing is disabled due to a bug in the api key plugin
|
||||||
apiKey({ disableKeyHashing: true }),
|
apiKey({ disableKeyHashing: true }),
|
||||||
magicLink({
|
magicLink({
|
||||||
@@ -65,6 +163,58 @@ export const initAuth = (db: dbClient) => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
databaseHooks: {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
before() {
|
||||||
|
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
return Promise.resolve(true);
|
||||||
|
},
|
||||||
|
async after(user) {
|
||||||
|
if (
|
||||||
|
user.image &&
|
||||||
|
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const client = new S3Client({
|
||||||
|
region: env("S3_REGION") ?? "",
|
||||||
|
endpoint: env("S3_ENDPOINT") ?? "",
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: env("S3_ACCESS_KEY_ID") ?? "",
|
||||||
|
secretAccessKey: env("S3_SECRET_ACCESS_KEY") ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
||||||
|
|
||||||
|
const fileExtension =
|
||||||
|
user.image.split(".").pop()?.split("?")[0] || "jpg";
|
||||||
|
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`;
|
||||||
|
|
||||||
|
const imageBuffer = await downloadImage(user.image);
|
||||||
|
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||||
|
Key: key,
|
||||||
|
Body: imageBuffer,
|
||||||
|
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`,
|
||||||
|
ACL: "public-read",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await userRepo.update(db, user.id, {
|
||||||
|
image: key,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
after: createAuthMiddleware(async (ctx) => {
|
after: createAuthMiddleware(async (ctx) => {
|
||||||
if (ctx.path.startsWith("/get-session")) {
|
if (ctx.path.startsWith("/get-session")) {
|
||||||
|
|||||||
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 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",
|
"extends": "@kan/tsconfig/internal-package.json",
|
||||||
"compilerOptions": {},
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
"include": ["*.ts", "src"],
|
"include": ["*.ts", "src"],
|
||||||
"exclude": ["node_modules"]
|
"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,
|
"when": 1748378293342,
|
||||||
"tag": "20250527203813_AddDeletedAtToLabel",
|
"tag": "20250527203813_AddDeletedAtToLabel",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1749118315675,
|
||||||
|
"tag": "20250605101155_AddCascadeDeleteToCardRelations",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1749405288761,
|
||||||
|
"tag": "20250608175448_AddOnDeleteActionToUserDeletion",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1749377372062,
|
||||||
|
"tag": "20250608100932_AddIntegrationsTable",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1749585889984,
|
||||||
|
"tag": "20250610200449_ReaddIntegrationTable",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { dbClient } from "@kan/db/client";
|
||||||
import type { BoardVisibilityStatus } from "@kan/db/schema";
|
import type { BoardVisibilityStatus } from "@kan/db/schema";
|
||||||
@@ -239,6 +239,7 @@ export const getBySlug = async (
|
|||||||
workspace: {
|
workspace: {
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
|
name: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -476,3 +477,34 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isBoardSlugAvailable = async (
|
||||||
|
db: dbClient,
|
||||||
|
boardSlug: string,
|
||||||
|
boardPublicId: string,
|
||||||
|
) => {
|
||||||
|
const result = await db
|
||||||
|
.select({ id: boards.id })
|
||||||
|
.from(boards)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(boards.publicId, boardPublicId),
|
||||||
|
exists(
|
||||||
|
db
|
||||||
|
.select({ id: boards.id })
|
||||||
|
.from(boards)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(boards.slug, boardSlug),
|
||||||
|
eq(boards.workspaceId, sql`${boards.workspaceId}`), // Reference outer query's workspaceId
|
||||||
|
isNull(boards.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return result.length === 0;
|
||||||
|
};
|
||||||
|
|||||||
@@ -731,6 +731,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
|||||||
board: {
|
board: {
|
||||||
columns: {
|
columns: {
|
||||||
workspaceId: true,
|
workspaceId: true,
|
||||||
|
visibility: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -742,6 +743,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
|||||||
? {
|
? {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
workspaceId: result.list.board.workspaceId,
|
workspaceId: result.list.board.workspaceId,
|
||||||
|
workspaceVisibility: result.list.board.visibility,
|
||||||
}
|
}
|
||||||
: null;
|
: 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, {
|
user: one(users, {
|
||||||
fields: [apikey.userId],
|
fields: [apikey.userId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "apiKeyUser",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -33,13 +33,15 @@ export const boards = pgTable(
|
|||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
slug: varchar("slug", { length: 255 }).notNull(),
|
slug: varchar("slug", { length: 255 }).notNull(),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
importId: bigint("importId", { mode: "number" }).references(
|
importId: bigint("importId", { mode: "number" }).references(
|
||||||
() => imports.id,
|
() => imports.id,
|
||||||
),
|
),
|
||||||
@@ -60,19 +62,23 @@ export const boardsRelations = relations(boards, ({ one, many }) => ({
|
|||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [boards.createdBy],
|
fields: [boards.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "boardCreatedByUser",
|
||||||
}),
|
}),
|
||||||
lists: many(lists),
|
lists: many(lists),
|
||||||
labels: many(labels),
|
labels: many(labels),
|
||||||
deletedBy: one(users, {
|
deletedBy: one(users, {
|
||||||
fields: [boards.deletedBy],
|
fields: [boards.deletedBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "boardDeletedByUser",
|
||||||
}),
|
}),
|
||||||
import: one(imports, {
|
import: one(imports, {
|
||||||
fields: [boards.importId],
|
fields: [boards.importId],
|
||||||
references: [imports.id],
|
references: [imports.id],
|
||||||
|
relationName: "boardImport",
|
||||||
}),
|
}),
|
||||||
workspace: one(workspaces, {
|
workspace: one(workspaces, {
|
||||||
fields: [boards.workspaceId],
|
fields: [boards.workspaceId],
|
||||||
references: [workspaces.id],
|
references: [workspaces.id],
|
||||||
|
relationName: "boardWorkspace",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -44,13 +44,15 @@ export const cards = pgTable("card", {
|
|||||||
title: varchar("title", { length: 255 }).notNull(),
|
title: varchar("title", { length: 255 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
index: integer("index").notNull(),
|
index: integer("index").notNull(),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
listId: bigint("listId", { mode: "number" })
|
listId: bigint("listId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => lists.id, { onDelete: "cascade" }),
|
.references(() => lists.id, { onDelete: "cascade" }),
|
||||||
@@ -61,20 +63,24 @@ export const cardsRelations = relations(cards, ({ one, many }) => ({
|
|||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [cards.createdBy],
|
fields: [cards.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "cardsCreatedByUser",
|
||||||
}),
|
}),
|
||||||
list: one(lists, {
|
list: one(lists, {
|
||||||
fields: [cards.listId],
|
fields: [cards.listId],
|
||||||
references: [lists.id],
|
references: [lists.id],
|
||||||
|
relationName: "cardsList",
|
||||||
}),
|
}),
|
||||||
deletedBy: one(users, {
|
deletedBy: one(users, {
|
||||||
fields: [cards.deletedBy],
|
fields: [cards.deletedBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "cardsDeletedByUser",
|
||||||
}),
|
}),
|
||||||
labels: many(cardsToLabels),
|
labels: many(cardsToLabels),
|
||||||
members: many(cardToWorkspaceMembers),
|
members: many(cardToWorkspaceMembers),
|
||||||
import: one(imports, {
|
import: one(imports, {
|
||||||
fields: [cards.importId],
|
fields: [cards.importId],
|
||||||
references: [imports.id],
|
references: [imports.id],
|
||||||
|
relationName: "cardsImport",
|
||||||
}),
|
}),
|
||||||
comments: many(comments),
|
comments: many(comments),
|
||||||
activities: many(cardActivities),
|
activities: many(cardActivities),
|
||||||
@@ -91,22 +97,28 @@ export const cardActivities = pgTable("card_activity", {
|
|||||||
toIndex: integer("toIndex"),
|
toIndex: integer("toIndex"),
|
||||||
fromListId: bigint("fromListId", { mode: "number" }).references(
|
fromListId: bigint("fromListId", { mode: "number" }).references(
|
||||||
() => lists.id,
|
() => lists.id,
|
||||||
|
{ onDelete: "cascade" },
|
||||||
),
|
),
|
||||||
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id),
|
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id, {
|
||||||
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id),
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
workspaceMemberId: bigint("workspaceMemberId", {
|
workspaceMemberId: bigint("workspaceMemberId", {
|
||||||
mode: "number",
|
mode: "number",
|
||||||
}).references(() => workspaceMembers.id),
|
}).references(() => workspaceMembers.id, { onDelete: "set null" }),
|
||||||
fromTitle: varchar("fromTitle", { length: 255 }),
|
fromTitle: varchar("fromTitle", { length: 255 }),
|
||||||
toTitle: varchar("toTitle", { length: 255 }),
|
toTitle: varchar("toTitle", { length: 255 }),
|
||||||
fromDescription: text("fromDescription"),
|
fromDescription: text("fromDescription"),
|
||||||
toDescription: text("toDescription"),
|
toDescription: text("toDescription"),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
commentId: bigint("commentId", { mode: "number" }).references(
|
commentId: bigint("commentId", { mode: "number" }).references(
|
||||||
() => comments.id,
|
() => comments.id,
|
||||||
|
{ onDelete: "cascade" },
|
||||||
),
|
),
|
||||||
fromComment: text("fromComment"),
|
fromComment: text("fromComment"),
|
||||||
toComment: text("toComment"),
|
toComment: text("toComment"),
|
||||||
@@ -116,34 +128,42 @@ export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
|||||||
card: one(cards, {
|
card: one(cards, {
|
||||||
fields: [cardActivities.cardId],
|
fields: [cardActivities.cardId],
|
||||||
references: [cards.id],
|
references: [cards.id],
|
||||||
|
relationName: "cardActivitiesCard",
|
||||||
}),
|
}),
|
||||||
fromList: one(lists, {
|
fromList: one(lists, {
|
||||||
fields: [cardActivities.fromListId],
|
fields: [cardActivities.fromListId],
|
||||||
references: [lists.id],
|
references: [lists.id],
|
||||||
|
relationName: "cardActivitiesFromList",
|
||||||
}),
|
}),
|
||||||
toList: one(lists, {
|
toList: one(lists, {
|
||||||
fields: [cardActivities.toListId],
|
fields: [cardActivities.toListId],
|
||||||
references: [lists.id],
|
references: [lists.id],
|
||||||
|
relationName: "cardActivitiesToList",
|
||||||
}),
|
}),
|
||||||
label: one(labels, {
|
label: one(labels, {
|
||||||
fields: [cardActivities.labelId],
|
fields: [cardActivities.labelId],
|
||||||
references: [labels.id],
|
references: [labels.id],
|
||||||
|
relationName: "cardActivitiesLabel",
|
||||||
}),
|
}),
|
||||||
workspaceMember: one(workspaceMembers, {
|
workspaceMember: one(workspaceMembers, {
|
||||||
fields: [cardActivities.workspaceMemberId],
|
fields: [cardActivities.workspaceMemberId],
|
||||||
references: [workspaceMembers.id],
|
references: [workspaceMembers.id],
|
||||||
|
relationName: "cardActivitiesWorkspaceMember",
|
||||||
}),
|
}),
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [cardActivities.createdBy],
|
fields: [cardActivities.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "cardActivitiesUser",
|
||||||
}),
|
}),
|
||||||
member: one(workspaceMembers, {
|
member: one(workspaceMembers, {
|
||||||
fields: [cardActivities.workspaceMemberId],
|
fields: [cardActivities.workspaceMemberId],
|
||||||
references: [workspaceMembers.id],
|
references: [workspaceMembers.id],
|
||||||
|
relationName: "cardActivitiesMember",
|
||||||
}),
|
}),
|
||||||
comment: one(comments, {
|
comment: one(comments, {
|
||||||
fields: [cardActivities.commentId],
|
fields: [cardActivities.commentId],
|
||||||
references: [comments.id],
|
references: [comments.id],
|
||||||
|
relationName: "cardActivitiesComment",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -152,7 +172,7 @@ export const cardsToLabels = pgTable(
|
|||||||
{
|
{
|
||||||
cardId: bigint("cardId", { mode: "number" })
|
cardId: bigint("cardId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => cards.id),
|
.references(() => cards.id, { onDelete: "cascade" }),
|
||||||
labelId: bigint("labelId", { mode: "number" })
|
labelId: bigint("labelId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => labels.id, { onDelete: "cascade" }),
|
.references(() => labels.id, { onDelete: "cascade" }),
|
||||||
@@ -164,10 +184,12 @@ export const cardToLabelsRelations = relations(cardsToLabels, ({ one }) => ({
|
|||||||
card: one(cards, {
|
card: one(cards, {
|
||||||
fields: [cardsToLabels.cardId],
|
fields: [cardsToLabels.cardId],
|
||||||
references: [cards.id],
|
references: [cards.id],
|
||||||
|
relationName: "cardToLabelsCard",
|
||||||
}),
|
}),
|
||||||
label: one(labels, {
|
label: one(labels, {
|
||||||
fields: [cardsToLabels.labelId],
|
fields: [cardsToLabels.labelId],
|
||||||
references: [labels.id],
|
references: [labels.id],
|
||||||
|
relationName: "cardToLabelsLabel",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -176,7 +198,7 @@ export const cardToWorkspaceMembers = pgTable(
|
|||||||
{
|
{
|
||||||
cardId: bigint("cardId", { mode: "number" })
|
cardId: bigint("cardId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => cards.id),
|
.references(() => cards.id, { onDelete: "cascade" }),
|
||||||
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" })
|
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => workspaceMembers.id, { onDelete: "cascade" }),
|
.references(() => workspaceMembers.id, { onDelete: "cascade" }),
|
||||||
@@ -190,10 +212,12 @@ export const cardToWorkspaceMembersRelations = relations(
|
|||||||
card: one(cards, {
|
card: one(cards, {
|
||||||
fields: [cardToWorkspaceMembers.cardId],
|
fields: [cardToWorkspaceMembers.cardId],
|
||||||
references: [cards.id],
|
references: [cards.id],
|
||||||
|
relationName: "cardToWorkspaceMembersCard",
|
||||||
}),
|
}),
|
||||||
member: one(workspaceMembers, {
|
member: one(workspaceMembers, {
|
||||||
fields: [cardToWorkspaceMembers.workspaceMemberId],
|
fields: [cardToWorkspaceMembers.workspaceMemberId],
|
||||||
references: [workspaceMembers.id],
|
references: [workspaceMembers.id],
|
||||||
|
relationName: "cardToWorkspaceMembersMember",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -205,26 +229,31 @@ export const comments = pgTable("card_comments", {
|
|||||||
cardId: bigint("cardId", { mode: "number" })
|
cardId: bigint("cardId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => cards.id, { onDelete: "cascade" }),
|
.references(() => cards.id, { onDelete: "cascade" }),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
export const commentsRelations = relations(comments, ({ one }) => ({
|
export const commentsRelations = relations(comments, ({ one }) => ({
|
||||||
card: one(cards, {
|
card: one(cards, {
|
||||||
fields: [comments.cardId],
|
fields: [comments.cardId],
|
||||||
references: [cards.id],
|
references: [cards.id],
|
||||||
|
relationName: "commentsCard",
|
||||||
}),
|
}),
|
||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [comments.createdBy],
|
fields: [comments.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "commentsCreatedByUser",
|
||||||
}),
|
}),
|
||||||
deletedBy: one(users, {
|
deletedBy: one(users, {
|
||||||
fields: [comments.deletedBy],
|
fields: [comments.deletedBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "commentsDeletedByUser",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import { users } from "./users";
|
|||||||
export const feedback = pgTable("feedback", {
|
export const feedback = pgTable("feedback", {
|
||||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
feedback: text("feedback").notNull(),
|
feedback: text("feedback").notNull(),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
url: text("url").notNull(),
|
url: text("url").notNull(),
|
||||||
@@ -26,5 +26,6 @@ export const feedbackRelations = relations(feedback, ({ one }) => ({
|
|||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [feedback.createdBy],
|
fields: [feedback.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "feedbackCreatedByUser",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ export const imports = pgTable("import", {
|
|||||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
source: importSourceEnum("source").notNull(),
|
source: importSourceEnum("source").notNull(),
|
||||||
status: importStatusEnum("status").notNull(),
|
status: importStatusEnum("status").notNull(),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ export const importsRelations = relations(imports, ({ one, many }) => ({
|
|||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [imports.createdBy],
|
fields: [imports.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "importsCreatedByUser",
|
||||||
}),
|
}),
|
||||||
boards: many(boards),
|
boards: many(boards),
|
||||||
cards: many(cards),
|
cards: many(cards),
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export * from "./imports";
|
|||||||
export * from "./labels";
|
export * from "./labels";
|
||||||
export * from "./lists";
|
export * from "./lists";
|
||||||
export * from "./users";
|
export * from "./users";
|
||||||
|
export * from "./integrations";
|
||||||
export * from "./workspaces";
|
export * from "./workspaces";
|
||||||
|
|||||||
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(),
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
colourCode: varchar("colourCode", { length: 12 }),
|
colourCode: varchar("colourCode", { length: 12 }),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
boardId: bigint("boardId", { mode: "number" })
|
boardId: bigint("boardId", { mode: "number" })
|
||||||
@@ -28,17 +28,21 @@ export const labels = pgTable("label", {
|
|||||||
.references(() => boards.id, { onDelete: "cascade" }),
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
|
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
export const labelsRelations = relations(labels, ({ one, many }) => ({
|
export const labelsRelations = relations(labels, ({ one, many }) => ({
|
||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [labels.createdBy],
|
fields: [labels.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "labelsCreatedByUser",
|
||||||
}),
|
}),
|
||||||
deletedBy: one(users, {
|
deletedBy: one(users, {
|
||||||
fields: [labels.deletedBy],
|
fields: [labels.deletedBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "labelsDeletedByUser",
|
||||||
}),
|
}),
|
||||||
board: one(boards, {
|
board: one(boards, {
|
||||||
fields: [labels.boardId],
|
fields: [labels.boardId],
|
||||||
@@ -48,5 +52,6 @@ export const labelsRelations = relations(labels, ({ one, many }) => ({
|
|||||||
import: one(imports, {
|
import: one(imports, {
|
||||||
fields: [labels.importId],
|
fields: [labels.importId],
|
||||||
references: [imports.id],
|
references: [imports.id],
|
||||||
|
relationName: "labelsImport",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -19,13 +19,15 @@ export const lists = pgTable("list", {
|
|||||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
index: integer("index").notNull(),
|
index: integer("index").notNull(),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
boardId: bigint("boardId", { mode: "number" })
|
boardId: bigint("boardId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => boards.id, { onDelete: "cascade" }),
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
@@ -36,18 +38,22 @@ export const listsRelations = relations(lists, ({ one, many }) => ({
|
|||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
fields: [lists.createdBy],
|
fields: [lists.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "listsCreatedByUser",
|
||||||
}),
|
}),
|
||||||
board: one(boards, {
|
board: one(boards, {
|
||||||
fields: [lists.boardId],
|
fields: [lists.boardId],
|
||||||
references: [boards.id],
|
references: [boards.id],
|
||||||
|
relationName: "listsBoard",
|
||||||
}),
|
}),
|
||||||
cards: many(cards),
|
cards: many(cards),
|
||||||
deletedBy: one(users, {
|
deletedBy: one(users, {
|
||||||
fields: [lists.deletedBy],
|
fields: [lists.deletedBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "listsDeletedByUser",
|
||||||
}),
|
}),
|
||||||
import: one(imports, {
|
import: one(imports, {
|
||||||
fields: [lists.importId],
|
fields: [lists.importId],
|
||||||
references: [imports.id],
|
references: [imports.id],
|
||||||
|
relationName: "listsImport",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { cards } from "./cards";
|
|||||||
import { imports } from "./imports";
|
import { imports } from "./imports";
|
||||||
import { lists } from "./lists";
|
import { lists } from "./lists";
|
||||||
import { workspaceMembers, workspaces } from "./workspaces";
|
import { workspaceMembers, workspaces } from "./workspaces";
|
||||||
|
import { integrations } from "./integrations";
|
||||||
|
|
||||||
export const users = pgTable("user", {
|
export const users = pgTable("user", {
|
||||||
id: uuid("id")
|
id: uuid("id")
|
||||||
@@ -29,12 +30,33 @@ export const users = pgTable("user", {
|
|||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
boards: many(boards),
|
deletedBoards: many(boards, {
|
||||||
cards: many(cards),
|
relationName: "boardDeletedByUser",
|
||||||
|
}),
|
||||||
|
boards: many(boards, {
|
||||||
|
relationName: "boardCreatedByUser",
|
||||||
|
}),
|
||||||
|
deletedCards: many(cards, {
|
||||||
|
relationName: "cardsDeletedByUser",
|
||||||
|
}),
|
||||||
|
cards: many(cards, {
|
||||||
|
relationName: "cardsCreatedByUser",
|
||||||
|
}),
|
||||||
imports: many(imports),
|
imports: many(imports),
|
||||||
lists: many(lists),
|
deletedLists: many(lists, {
|
||||||
workspaces: many(workspaces),
|
relationName: "listsDeletedByUser",
|
||||||
|
}),
|
||||||
|
lists: many(lists, {
|
||||||
|
relationName: "listsCreatedByUser",
|
||||||
|
}),
|
||||||
|
deletedWorkspaces: many(workspaces, {
|
||||||
|
relationName: "workspaceDeletedByUser",
|
||||||
|
}),
|
||||||
|
workspaces: many(workspaces, {
|
||||||
|
relationName: "workspaceCreatedByUser",
|
||||||
|
}),
|
||||||
apiKeys: many(apikey),
|
apiKeys: many(apikey),
|
||||||
|
integrations: many(integrations),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const usersToWorkspacesRelations = relations(
|
export const usersToWorkspacesRelations = relations(
|
||||||
@@ -43,18 +65,22 @@ export const usersToWorkspacesRelations = relations(
|
|||||||
addedBy: one(users, {
|
addedBy: one(users, {
|
||||||
fields: [workspaceMembers.createdBy],
|
fields: [workspaceMembers.createdBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "usersToWorkspacesAddedByUser",
|
||||||
}),
|
}),
|
||||||
deletedBy: one(users, {
|
deletedBy: one(users, {
|
||||||
fields: [workspaceMembers.deletedBy],
|
fields: [workspaceMembers.deletedBy],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "usersToWorkspacesDeletedByUser",
|
||||||
}),
|
}),
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [workspaceMembers.userId],
|
fields: [workspaceMembers.userId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "usersToWorkspacesUser",
|
||||||
}),
|
}),
|
||||||
workspace: one(workspaces, {
|
workspace: one(workspaces, {
|
||||||
fields: [workspaceMembers.workspaceId],
|
fields: [workspaceMembers.workspaceId],
|
||||||
references: [workspaces.id],
|
references: [workspaces.id],
|
||||||
|
relationName: "usersToWorkspacesWorkspace",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,17 +36,28 @@ export const workspaces = pgTable("workspace", {
|
|||||||
description: text("description"),
|
description: text("description"),
|
||||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||||
plan: workspacePlanEnum("plan").notNull().default("free"),
|
plan: workspacePlanEnum("plan").notNull().default("free"),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => users.id),
|
}),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
|
export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
|
||||||
user: one(users, { 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),
|
members: many(workspaceMembers),
|
||||||
boards: many(boards),
|
boards: many(boards),
|
||||||
}));
|
}));
|
||||||
@@ -55,7 +66,7 @@ export const workspaceMembers = pgTable("workspace_members", {
|
|||||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
email: varchar("email", { length: 255 }).notNull(),
|
email: varchar("email", { length: 255 }).notNull(),
|
||||||
userId: uuid("userId").references(() => users.id),
|
userId: uuid("userId").references(() => users.id, { onDelete: "set null" }),
|
||||||
workspaceId: bigint("workspaceId", { mode: "number" })
|
workspaceId: bigint("workspaceId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||||
@@ -63,7 +74,9 @@ export const workspaceMembers = pgTable("workspace_members", {
|
|||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
deletedAt: timestamp("deletedAt"),
|
deletedAt: timestamp("deletedAt"),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
role: memberRoleEnum("role").notNull(),
|
role: memberRoleEnum("role").notNull(),
|
||||||
status: memberStatusEnum("status").default("invited").notNull(),
|
status: memberStatusEnum("status").default("invited").notNull(),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
@@ -74,10 +87,12 @@ export const workspaceMembersRelations = relations(
|
|||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [workspaceMembers.userId],
|
fields: [workspaceMembers.userId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
|
relationName: "workspaceMembersUser",
|
||||||
}),
|
}),
|
||||||
workspace: one(workspaces, {
|
workspace: one(workspaces, {
|
||||||
fields: [workspaceMembers.workspaceId],
|
fields: [workspaceMembers.workspaceId],
|
||||||
references: [workspaces.id],
|
references: [workspaces.id],
|
||||||
|
relationName: "workspaceMembersWorkspace",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,12 +24,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-email/components": "^0.0.25",
|
"@react-email/components": "^0.0.25",
|
||||||
|
"nodemailer": "^7.0.3",
|
||||||
"react-email": "^3.0.1"
|
"react-email": "^3.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kan/eslint-config": "workspace:*",
|
"@kan/eslint-config": "workspace:*",
|
||||||
"@kan/prettier-config": "workspace:*",
|
"@kan/prettier-config": "workspace:*",
|
||||||
"@kan/tsconfig": "workspace:*",
|
"@kan/tsconfig": "workspace:*",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
|
|||||||
@@ -1,42 +1,62 @@
|
|||||||
import { render } from "@react-email/render";
|
import { render } from "@react-email/render";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
import JoinWorkspaceTemplate from "./templates/join-workspace";
|
import JoinWorkspaceTemplate from "./templates/join-workspace";
|
||||||
import MagicLinkTemplate from "./templates/magic-link";
|
import MagicLinkTemplate from "./templates/magic-link";
|
||||||
|
import ResetPasswordTemplate from "./templates/reset-password";
|
||||||
|
|
||||||
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE";
|
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD";
|
||||||
|
|
||||||
const emailTemplates: Record<Templates, React.FC> = {
|
const emailTemplates: Record<Templates, React.FC> = {
|
||||||
MAGIC_LINK: MagicLinkTemplate,
|
MAGIC_LINK: MagicLinkTemplate,
|
||||||
JOIN_WORKSPACE: JoinWorkspaceTemplate,
|
JOIN_WORKSPACE: JoinWorkspaceTemplate,
|
||||||
|
RESET_PASSWORD: ResetPasswordTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
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 (
|
export const sendEmail = async (
|
||||||
to: string,
|
to: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
template: Templates,
|
template: Templates,
|
||||||
data: Record<string, string>,
|
data: Record<string, string>,
|
||||||
) => {
|
) => {
|
||||||
const EmailTemplate = emailTemplates[template];
|
try {
|
||||||
|
const EmailTemplate = emailTemplates[template];
|
||||||
|
|
||||||
const html = await render(<EmailTemplate {...data} />, { pretty: true });
|
const html = await render(<EmailTemplate {...data} />, { pretty: true });
|
||||||
|
|
||||||
const response = await fetch(process.env.EMAIL_URL!, {
|
const options = {
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${process.env.EMAIL_TOKEN}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
from: process.env.EMAIL_FROM,
|
from: process.env.EMAIL_FROM,
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
}),
|
};
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
const response = await transporter.sendMail(options);
|
||||||
throw new Error(`Failed to send email: ${response.statusText}`);
|
|
||||||
|
if (!response.accepted.length) {
|
||||||
|
throw new Error(`Failed to send email: ${response.response}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Email sending failed:", {
|
||||||
|
to,
|
||||||
|
from: process.env.EMAIL_FROM,
|
||||||
|
subject,
|
||||||
|
template,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
|
||||||
};
|
};
|
||||||
|
|||||||
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;
|
||||||
1252
pnpm-lock.yaml
generated
1252
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
47
turbo.json
47
turbo.json
@@ -47,11 +47,50 @@
|
|||||||
},
|
},
|
||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
"POSTGRES_URL",
|
"POSTGRES_URL",
|
||||||
|
"TRELLO_APP_API_KEY",
|
||||||
|
"TRELLO_APP_SECRET",
|
||||||
"GOOGLE_CLIENT_ID",
|
"GOOGLE_CLIENT_ID",
|
||||||
"GOOGLE_CLIENT_SECRET",
|
"GOOGLE_CLIENT_SECRET",
|
||||||
|
"DISCORD_CLIENT_ID",
|
||||||
|
"DISCORD_CLIENT_SECRET",
|
||||||
|
"GITHUB_CLIENT_ID",
|
||||||
|
"GITHUB_CLIENT_SECRET",
|
||||||
|
"GITLAB_CLIENT_ID",
|
||||||
|
"GITLAB_CLIENT_SECRET",
|
||||||
|
"GITLAB_ISSUER",
|
||||||
|
"MICROSOFT_CLIENT_ID",
|
||||||
|
"MICROSOFT_CLIENT_SECRET",
|
||||||
|
"TWITTER_CLIENT_ID",
|
||||||
|
"TWITTER_CLIENT_SECRET",
|
||||||
|
"KICK_CLIENT_ID",
|
||||||
|
"KICK_CLIENT_SECRET",
|
||||||
|
"ZOOM_CLIENT_ID",
|
||||||
|
"ZOOM_CLIENT_SECRET",
|
||||||
|
"DROPBOX_CLIENT_ID",
|
||||||
|
"DROPBOX_CLIENT_SECRET",
|
||||||
|
"VK_CLIENT_ID",
|
||||||
|
"VK_CLIENT_SECRET",
|
||||||
|
"LINKEDIN_CLIENT_ID",
|
||||||
|
"LINKEDIN_CLIENT_SECRET",
|
||||||
|
"REDDIT_CLIENT_ID",
|
||||||
|
"REDDIT_CLIENT_SECRET",
|
||||||
|
"ROBLOX_CLIENT_ID",
|
||||||
|
"ROBLOX_CLIENT_SECRET",
|
||||||
|
"SPOTIFY_CLIENT_ID",
|
||||||
|
"SPOTIFY_CLIENT_SECRET",
|
||||||
|
"TIKTOK_CLIENT_ID",
|
||||||
|
"TIKTOK_CLIENT_SECRET",
|
||||||
|
"TIKTOK_CLIENT_KEY",
|
||||||
|
"TWITCH_CLIENT_ID",
|
||||||
|
"TWITCH_CLIENT_SECRET",
|
||||||
|
"APPLE_CLIENT_ID",
|
||||||
|
"APPLE_CLIENT_SECRET",
|
||||||
|
"APPLE_APP_BUNDLE_IDENTIFIER",
|
||||||
"EMAIL_FROM",
|
"EMAIL_FROM",
|
||||||
"EMAIL_URL",
|
"SMTP_HOST",
|
||||||
"EMAIL_TOKEN",
|
"SMTP_PORT",
|
||||||
|
"SMTP_USER",
|
||||||
|
"SMTP_PASSWORD",
|
||||||
"NEXT_PUBLIC_KAN_ENV",
|
"NEXT_PUBLIC_KAN_ENV",
|
||||||
"STRIPE_SECRET_KEY",
|
"STRIPE_SECRET_KEY",
|
||||||
"STRIPE_WEBHOOK_SECRET",
|
"STRIPE_WEBHOOK_SECRET",
|
||||||
@@ -59,13 +98,15 @@
|
|||||||
"NEXT_PUBLIC_STORAGE_DOMAIN",
|
"NEXT_PUBLIC_STORAGE_DOMAIN",
|
||||||
"NEXT_PUBLIC_STORAGE_URL",
|
"NEXT_PUBLIC_STORAGE_URL",
|
||||||
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
|
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
|
||||||
|
"NEXT_PUBLIC_ALLOW_CREDENTIALS",
|
||||||
|
"NEXT_PUBLIC_DISABLE_SIGN_UP",
|
||||||
|
"NEXT_PUBLIC_USE_STANDALONE_OUTPUT",
|
||||||
"S3_REGION",
|
"S3_REGION",
|
||||||
"S3_ACCESS_KEY_ID",
|
"S3_ACCESS_KEY_ID",
|
||||||
"S3_SECRET_ACCESS_KEY",
|
"S3_SECRET_ACCESS_KEY",
|
||||||
"S3_ENDPOINT",
|
"S3_ENDPOINT",
|
||||||
"PORT",
|
"PORT",
|
||||||
"BETTER_AUTH_SECRET",
|
"BETTER_AUTH_SECRET",
|
||||||
"BETTER_AUTH_URL",
|
|
||||||
"BETTER_AUTH_TRUSTED_ORIGINS"
|
"BETTER_AUTH_TRUSTED_ORIGINS"
|
||||||
],
|
],
|
||||||
"globalPassThroughEnv": [
|
"globalPassThroughEnv": [
|
||||||
|
|||||||
Reference in New Issue
Block a user