Compare commits
3 Commits
fix/8-self
...
feat/docke
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2062908319 | ||
|
|
4a8c99929c | ||
|
|
78e543d321 |
42
.env.example
42
.env.example
@@ -1,10 +1,8 @@
|
|||||||
POSTGRES_URL=
|
POSTGRES_URL=
|
||||||
|
|
||||||
EMAIL_FROM=
|
EMAIL_FROM=
|
||||||
SMTP_HOST=
|
EMAIL_URL=
|
||||||
SMTP_PORT=
|
EMAIL_TOKEN=
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASSWORD=
|
|
||||||
|
|
||||||
NEXT_PUBLIC_BASE_URL=
|
NEXT_PUBLIC_BASE_URL=
|
||||||
NEXT_PUBLIC_STORAGE_URL=
|
NEXT_PUBLIC_STORAGE_URL=
|
||||||
@@ -21,39 +19,3 @@ 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
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
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
30
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
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
105
.github/workflows/docker-publish.yml
vendored
@@ -1,105 +0,0 @@
|
|||||||
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}
|
|
||||||
89
README.md
89
README.md
@@ -45,37 +45,6 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
|||||||
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
|
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
|
||||||
- [React Email](https://react.email/?ref=kan.bn)
|
- [React Email](https://react.email/?ref=kan.bn)
|
||||||
|
|
||||||
## Self Hosting 🐳
|
|
||||||
|
|
||||||
### PostgreSQL Database Setup
|
|
||||||
|
|
||||||
Kan requires a PostgreSQL database. You can run one using the official PostgreSQL Docker image:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run PostgreSQL in a container using the official postgres image
|
|
||||||
docker run -d \
|
|
||||||
--name kan-db \
|
|
||||||
-e POSTGRES_DB=kan \
|
|
||||||
-e POSTGRES_USER=kan_user \
|
|
||||||
-e POSTGRES_PASSWORD=your_secure_password \
|
|
||||||
-p 5432:5432 \
|
|
||||||
-v kan_postgres_data:/var/lib/postgresql/data \
|
|
||||||
postgres:15
|
|
||||||
|
|
||||||
# Your POSTGRES_URL should be:
|
|
||||||
# postgres://kan_user:your_secure_password@your_host:5432/kan
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kan Application Deployment
|
|
||||||
|
|
||||||
Deploy Kan with Docker using our pre-built image:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull ghcr.io/kanbn/kan:latest && docker run -it -p 3000:3000 --env-file .env ghcr.io/kanbn/kan:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Make sure to create a `.env` file with the required environment variables (see the Environment Variables section below).
|
|
||||||
|
|
||||||
## Local Development 🧑💻
|
## Local Development 🧑💻
|
||||||
|
|
||||||
1. Clone the repository (or fork)
|
1. Clone the repository (or fork)
|
||||||
@@ -91,13 +60,7 @@ 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. Migrate database
|
4. Start the development server
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm db:migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Start the development server
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev
|
pnpm dev
|
||||||
@@ -105,31 +68,25 @@ 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 | Yes | `"Kan <hello@mail.kan.bn>"` |
|
||||||
| `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
|
| `EMAIL_URL` | Email service API URL | Yes | `https://api.resend.com/emails` |
|
||||||
| `SMTP_PORT` | SMTP server port | Yes | `465` |
|
| `EMAIL_TOKEN` | Email service API token | Yes | `re_xxxx` |
|
||||||
| `SMTP_USER` | SMTP username/email | Yes | `resend` |
|
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||||
| `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` |
|
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
| `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
|
||||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | For Google login | `http://localhost:3000,http://localhost:3001` |
|
||||||
| `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | Yes | `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` |
|
||||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
|
||||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
|
||||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
|
||||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
|
||||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
|
||||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
|
||||||
|
|
||||||
See `.env.example` for a complete list of supported environment variables.
|
See `.env.example` for a complete list of supported environment variables.
|
||||||
|
|
||||||
@@ -137,12 +94,6 @@ See `.env.example` for a complete list of supported environment variables.
|
|||||||
|
|
||||||
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
|
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
|
||||||
|
|
||||||
## Contributors 👥
|
|
||||||
|
|
||||||
<a href="https://github.com/kanbn/kan/graphs/contributors">
|
|
||||||
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## License 📝
|
## License 📝
|
||||||
|
|
||||||
Kan is licensed under the [AGPLv3 license](LICENSE).
|
Kan is licensed under the [AGPLv3 license](LICENSE).
|
||||||
|
|||||||
@@ -25,11 +25,7 @@ Get started with Kan by choosing your preferred deployment option below.
|
|||||||
Get started instantly with our hosted solution - we'll handle all the
|
Get started instantly with our hosted solution - we'll handle all the
|
||||||
hosting, scaling, and maintenance for you.
|
hosting, scaling, and maintenance for you.
|
||||||
</Card>
|
</Card>
|
||||||
<Card
|
<Card title="Self-host" icon="code" href="https://github.com/kanbn/kan">
|
||||||
title="Self-host"
|
|
||||||
icon="code"
|
|
||||||
href="https://github.com/kanbn/kan?tab=readme-ov-file#self-hosting-"
|
|
||||||
>
|
|
||||||
For full data ownership and control, you can self-host Kan on your
|
For full data ownership and control, you can self-host Kan on your
|
||||||
infrastructure.
|
infrastructure.
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ COPY . .
|
|||||||
|
|
||||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||||
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
RUN turbo prune --scope=${PROJECT} --docker
|
||||||
|
|
||||||
# 3. Build the project
|
# 3. Build the project
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
@@ -75,6 +75,5 @@ ARG PORT=3000
|
|||||||
ENV PORT=${PORT}
|
ENV PORT=${PORT}
|
||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
# Run migration on start for now (until we have a better way to handle this)
|
CMD ["pnpm", "start"]
|
||||||
CMD ["sh", "-c", "cd /app && pnpm db:migrate && cd /app/apps/web && pnpm start"]
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,20 +26,7 @@ const config = {
|
|||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
domains: [env("NEXT_PUBLIC_STORAGE_DOMAIN") ?? ""],
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
protocol: "http",
|
|
||||||
hostname: "localhost",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "*.googleusercontent.com",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
instrumentationHook: true,
|
instrumentationHook: true,
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
"aws-sdk": "^2.1692.0",
|
"aws-sdk": "^2.1692.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"next-logger": "^5.0.1",
|
"next-logger": "^5.0.1",
|
||||||
"next-runtime-env": "^1.7.2",
|
"next-runtime-env": "^1.7.2",
|
||||||
@@ -53,6 +55,7 @@
|
|||||||
"@kan/stripe": "workspace:*",
|
"@kan/stripe": "workspace:*",
|
||||||
"@kan/tailwind-config": "workspace:*",
|
"@kan/tailwind-config": "workspace:*",
|
||||||
"@kan/tsconfig": "workspace:*",
|
"@kan/tsconfig": "workspace:*",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^20.17.7",
|
"@types/node": "^20.17.7",
|
||||||
"@types/react": "catalog:react18",
|
"@types/react": "catalog:react18",
|
||||||
"@types/react-beautiful-dnd": "^13.1.7",
|
"@types/react-beautiful-dnd": "^13.1.7",
|
||||||
|
|||||||
@@ -1 +1,8 @@
|
|||||||
window.__ENV = {"NEXT_PUBLIC_UMAMI_ID":"aaed1f55-25e2-4223-b918-e429c390be35","NEXT_PUBLIC_KAN_ENV":"cloud","NEXT_PUBLIC_BASE_URL":"http://localhost:3000","NEXT_PUBLIC_AVATAR_BUCKET_NAME":"avatars","NEXT_PUBLIC_STORAGE_DOMAIN":"kanbn.com","NEXT_PUBLIC_STORAGE_URL":"https://storage.kanbn.com"};
|
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,14 +1,10 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { FaDiscord, FaGithub, FaGoogle, FaApple, FaMicrosoft, FaFacebook, FaSpotify, FaTwitch, FaTwitter, FaDropbox, FaLinkedin, FaGitlab, FaTiktok, FaReddit, FaVk } from "react-icons/fa";
|
import { FaGoogle } 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 type { SocialProvider } from "better-auth/social-providers";
|
|
||||||
|
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
@@ -23,103 +19,9 @@ interface AuthProps {
|
|||||||
|
|
||||||
const EmailSchema = z.object({ email: z.string().email() });
|
const EmailSchema = z.object({ email: z.string().email() });
|
||||||
|
|
||||||
const availableSocialProviders = {
|
|
||||||
google: {
|
|
||||||
id: "google",
|
|
||||||
name: "Google",
|
|
||||||
icon: FaGoogle,
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
id: "github",
|
|
||||||
name: "GitHub",
|
|
||||||
icon: FaGithub,
|
|
||||||
},
|
|
||||||
discord: {
|
|
||||||
id: "discord",
|
|
||||||
name: "Discord",
|
|
||||||
icon: FaDiscord,
|
|
||||||
},
|
|
||||||
apple: {
|
|
||||||
id: "apple",
|
|
||||||
name: "Apple",
|
|
||||||
icon: FaApple,
|
|
||||||
},
|
|
||||||
microsoft: {
|
|
||||||
id: "microsoft",
|
|
||||||
name: "Microsoft",
|
|
||||||
icon: FaMicrosoft,
|
|
||||||
},
|
|
||||||
facebook: {
|
|
||||||
id: "facebook",
|
|
||||||
name: "Facebook",
|
|
||||||
icon: FaFacebook,
|
|
||||||
},
|
|
||||||
spotify: {
|
|
||||||
id: "spotify",
|
|
||||||
name: "Spotify",
|
|
||||||
icon: FaSpotify,
|
|
||||||
},
|
|
||||||
twitch: {
|
|
||||||
id: "twitch",
|
|
||||||
name: "Twitch",
|
|
||||||
icon: FaTwitch,
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
id: "twitter",
|
|
||||||
name: "Twitter",
|
|
||||||
icon: FaTwitter,
|
|
||||||
},
|
|
||||||
dropbox: {
|
|
||||||
id: "dropbox",
|
|
||||||
name: "Dropbox",
|
|
||||||
icon: FaDropbox,
|
|
||||||
},
|
|
||||||
linkedin: {
|
|
||||||
id: "linkedin",
|
|
||||||
name: "LinkedIn",
|
|
||||||
icon: FaLinkedin,
|
|
||||||
},
|
|
||||||
gitlab: {
|
|
||||||
id: "gitlab",
|
|
||||||
name: "GitLab",
|
|
||||||
icon: FaGitlab,
|
|
||||||
},
|
|
||||||
tiktok: {
|
|
||||||
id: "tiktok",
|
|
||||||
name: "TikTok",
|
|
||||||
icon: FaTiktok,
|
|
||||||
},
|
|
||||||
reddit: {
|
|
||||||
id: "reddit",
|
|
||||||
name: "Reddit",
|
|
||||||
icon: FaReddit,
|
|
||||||
},
|
|
||||||
roblox: {
|
|
||||||
id: "roblox",
|
|
||||||
name: "Roblox",
|
|
||||||
icon: SiRoblox,
|
|
||||||
},
|
|
||||||
vk: {
|
|
||||||
id: "vk",
|
|
||||||
name: "VK",
|
|
||||||
icon: FaVk,
|
|
||||||
},
|
|
||||||
kick: {
|
|
||||||
id: "kick",
|
|
||||||
name: "Kick",
|
|
||||||
icon: TbBrandKick,
|
|
||||||
},
|
|
||||||
zoom: {
|
|
||||||
id: "zoom",
|
|
||||||
name: "Zoom",
|
|
||||||
icon: SiZoom,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
||||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState<
|
const [isLoginWithGooglePending, setIsLoginWithGooglePending] =
|
||||||
null | SocialProvider
|
useState(false);
|
||||||
>(null);
|
|
||||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -131,11 +33,6 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
resolver: zodResolver(EmailSchema),
|
resolver: zodResolver(EmailSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: socialProviders } = useQuery({
|
|
||||||
queryKey: ["social_providers"],
|
|
||||||
queryFn: () => authClient.getSocialProviders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleLoginWithEmail = async (email: string) => {
|
const handleLoginWithEmail = async (email: string) => {
|
||||||
setIsLoginWithEmailPending(true);
|
setIsLoginWithEmailPending(true);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
@@ -155,21 +52,18 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoginWithProvider = async (
|
const handleLoginWithGoogle = async () => {
|
||||||
provider: SocialProvider) => {
|
setIsLoginWithGooglePending(true);
|
||||||
setIsLoginWithProviderPending(provider);
|
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
const { error } = await authClient.signIn.social({
|
const { error } = await authClient.signIn.social({
|
||||||
provider,
|
provider: "google",
|
||||||
callbackURL: "/boards",
|
callbackURL: "/boards",
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsLoginWithProviderPending(null);
|
setIsLoginWithGooglePending(false);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setLoginError(
|
setLoginError("Failed to login with Google. Please try again.");
|
||||||
`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,35 +73,23 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{socialProviders?.length !== 0 && (
|
<div>
|
||||||
<div className="space-y-2">
|
<Button
|
||||||
{Object.entries(availableSocialProviders).map(([key, provider]) => {
|
onClick={handleLoginWithGoogle}
|
||||||
if (!socialProviders?.includes(key)) {
|
isLoading={isLoginWithGooglePending}
|
||||||
return null;
|
iconLeft={<FaGoogle />}
|
||||||
}
|
fullWidth
|
||||||
return (
|
size="lg"
|
||||||
<Button
|
>
|
||||||
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
Continue with Google
|
||||||
isLoading={isLoginWithProviderPending === key}
|
</Button>
|
||||||
iconLeft={<provider.icon />}
|
</div>
|
||||||
fullWidth
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
Continue with {provider.name}
|
|
||||||
</Button>
|
|
||||||
)})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
{socialProviders?.length !== 0 && (
|
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
<span className="text-sm text-light-900 dark:text-dark-900">or</span>
|
||||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
or
|
</div>
|
||||||
</span>
|
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Input
|
<Input
|
||||||
{...register("email", { required: true })}
|
{...register("email", { required: true })}
|
||||||
placeholder="Enter your email address"
|
placeholder="Enter your email address"
|
||||||
|
|||||||
@@ -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/client";
|
import { authClient } from "@kan/auth";
|
||||||
|
|
||||||
import { useTheme } from "~/providers/theme";
|
import { useTheme } from "~/providers/theme";
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
|
|||||||
@@ -21,46 +21,13 @@ export const env = createEnv({
|
|||||||
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(),
|
||||||
|
EMAIL_URL: z.string(),
|
||||||
|
EMAIL_TOKEN: z.string(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { toNodeHandler } from "better-auth/node";
|
import { toNodeHandler } from "better-auth/node";
|
||||||
|
|
||||||
import { initAuth } from "@kan/auth/server";
|
import { initAuth } from "@kan/auth";
|
||||||
import { createDrizzleClient } from "@kan/db/client";
|
import { createDrizzleClient } from "@kan/db/client";
|
||||||
|
|
||||||
export const config = { api: { bodyParser: false } };
|
export const config = { api: { bodyParser: false } };
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
import { env as nextRuntimeEnv } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
|
||||||
import { env } from "~/env";
|
|
||||||
|
|
||||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
const allowedContentTypes = ["image/jpeg", "image/png"];
|
||||||
|
|
||||||
@@ -56,7 +54,7 @@ export default async function handler(
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
client,
|
client,
|
||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||||
Key: filename,
|
Key: filename,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,10 +45,6 @@ export const formatMemberDisplayName = (
|
|||||||
return localPart.replace(/[_-]/g, ".");
|
return localPart.replace(/[_-]/g, ".");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAvatarUrl = (imageOrKey: string) => {
|
export const getAvatarUrl = (key: string) => {
|
||||||
if (imageOrKey.startsWith("http://") || imageOrKey.startsWith("https://")) {
|
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${key}`;
|
||||||
return imageOrKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
// import { useRouter } from "next/navigation";
|
||||||
import { Auth } from "~/components/AuthForm";
|
import { Auth } from "~/components/AuthForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
import { authClient } from "@kan/auth/client";
|
|
||||||
|
// import { api } from "~/utils/api";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
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>("");
|
||||||
|
|
||||||
@@ -17,9 +18,15 @@ export default function LoginPage() {
|
|||||||
setMagicLinkRecipient(recipient);
|
setMagicLinkRecipient(recipient);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data } = authClient.useSession();
|
// const authCookieExists = document.cookie
|
||||||
|
// .split("; ")
|
||||||
|
// .some((cookie) => cookie.includes("auth-token"));
|
||||||
|
|
||||||
if (data?.user.id) router.push("/boards");
|
// const { data } = api.user.getUser.useQuery(undefined, {
|
||||||
|
// enabled: authCookieExists ? true : false,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (data?.id) router.push("/boards");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import type { ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
HiOutlinePlusSmall,
|
||||||
|
HiEllipsisHorizontal,
|
||||||
|
HiOutlineTrash,
|
||||||
|
HiOutlineSquaresPlus,
|
||||||
|
} from "react-icons/hi2";
|
||||||
import { Draggable } from "react-beautiful-dnd";
|
import { Draggable } from "react-beautiful-dnd";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import {
|
|
||||||
HiEllipsisHorizontal,
|
import { api } from "~/utils/api";
|
||||||
HiOutlinePlusSmall,
|
import { useModal } from "~/providers/modal";
|
||||||
HiOutlineSquaresPlus,
|
|
||||||
HiOutlineTrash,
|
|
||||||
} from "react-icons/hi2";
|
|
||||||
|
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
import { useModal } from "~/providers/modal";
|
|
||||||
import { api } from "~/utils/api";
|
|
||||||
|
|
||||||
interface ListProps {
|
interface ListProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -79,20 +80,20 @@ export default function List({
|
|||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
|
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex justify-between">
|
<div className="flex justify-between">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
className="w-full focus-visible:outline-none"
|
className="focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
onBlur={handleSubmit(onSubmit)}
|
onBlur={handleSubmit(onSubmit)}
|
||||||
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
className="mb-4 block border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<div className="flex items-center">
|
<div>
|
||||||
<button
|
<button
|
||||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
|
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
|
||||||
onClick={() => openNewCardForm(list.publicId)}
|
onClick={() => openNewCardForm(list.publicId)}
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export function NewCardForm({
|
|||||||
const memberPublicIds = watch("memberPublicIds") || [];
|
const memberPublicIds = watch("memberPublicIds") || [];
|
||||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||||
const position = watch("position");
|
const position = watch("position");
|
||||||
const title = watch("title");
|
|
||||||
|
|
||||||
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
||||||
enabled: !!boardPublicId,
|
enabled: !!boardPublicId,
|
||||||
@@ -111,29 +110,16 @@ export function NewCardForm({
|
|||||||
|
|
||||||
return { previousState: currentState };
|
return { previousState: currentState };
|
||||||
},
|
},
|
||||||
onError: (error, _newList, context) => {
|
onError: (_error, _newList, context) => {
|
||||||
utils.board.byId.setData(queryParams, context?.previousState);
|
utils.board.byId.setData(queryParams, context?.previousState);
|
||||||
showPopup({
|
showPopup({
|
||||||
header: "Unable to create card",
|
header: "Unable to create card",
|
||||||
message: error.data?.zodError?.fieldErrors.title?.[0] ?
|
message: "Please try again later, or contact customer support.",
|
||||||
`${error.data?.zodError?.fieldErrors.title?.[0].replace("String", "Title")}` :
|
|
||||||
"Please try again later, or contact customer support.",
|
|
||||||
icon: "error",
|
icon: "error",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSettled: async () => {
|
||||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
|
||||||
if (!isCreateAnotherEnabled) closeModal();
|
|
||||||
await utils.board.byId.invalidate(queryParams);
|
await utils.board.byId.invalidate(queryParams);
|
||||||
reset({
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
listPublicId: watch("listPublicId"),
|
|
||||||
labelPublicIds: [],
|
|
||||||
memberPublicIds: [],
|
|
||||||
isCreateAnotherEnabled,
|
|
||||||
position,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,6 +164,18 @@ export function NewCardForm({
|
|||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
const onSubmit = (data: NewCardInput) => {
|
const onSubmit = (data: NewCardInput) => {
|
||||||
|
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||||
|
if (!isCreateAnotherEnabled) closeModal();
|
||||||
|
reset({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
listPublicId: watch("listPublicId"),
|
||||||
|
labelPublicIds: [],
|
||||||
|
memberPublicIds: [],
|
||||||
|
isCreateAnotherEnabled,
|
||||||
|
position,
|
||||||
|
});
|
||||||
|
|
||||||
createCard.mutate({
|
createCard.mutate({
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
@@ -389,7 +387,7 @@ export function NewCardForm({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button type="submit" disabled={title.length === 0 || createCard.isPending}>Create card</Button>
|
<Button type="submit">Create card</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { HiCheck, HiXMark } from "react-icons/hi2";
|
import { HiXMark } from "react-icons/hi2";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import { useDebounce } from "~/hooks/useDebounce";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -51,7 +49,6 @@ export function UpdateBoardSlugForm({
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isDirty, errors },
|
formState: { isDirty, errors },
|
||||||
watch,
|
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
values: {
|
values: {
|
||||||
@@ -60,10 +57,6 @@ export function UpdateBoardSlugForm({
|
|||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const slug = watch("slug");
|
|
||||||
|
|
||||||
const [debouncedSlug] = useDebounce(slug, 500);
|
|
||||||
|
|
||||||
const updateBoardSlug = api.board.update.useMutation({
|
const updateBoardSlug = api.board.update.useMutation({
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showPopup({
|
showPopup({
|
||||||
@@ -78,20 +71,6 @@ export function UpdateBoardSlugForm({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkBoardSlugAvailability =
|
|
||||||
api.board.checkSlugAvailability.useQuery(
|
|
||||||
{
|
|
||||||
boardSlug: debouncedSlug,
|
|
||||||
boardPublicId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled:
|
|
||||||
!!debouncedSlug && debouncedSlug !== boardSlug && !errors.slug,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const isBoardSlugAvailable = checkBoardSlugAvailability.data;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nameElement: HTMLElement | null =
|
const nameElement: HTMLElement | null =
|
||||||
document.querySelector<HTMLElement>("#board-slug");
|
document.querySelector<HTMLElement>("#board-slug");
|
||||||
@@ -99,9 +78,6 @@ export function UpdateBoardSlugForm({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onSubmit = (data: FormValues) => {
|
const onSubmit = (data: FormValues) => {
|
||||||
if (!isBoardSlugAvailable) return;
|
|
||||||
if (isBoardSlugAvailable?.isReserved) return;
|
|
||||||
|
|
||||||
updateBoardSlug.mutate({
|
updateBoardSlug.mutate({
|
||||||
slug: data.slug,
|
slug: data.slug,
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
@@ -130,23 +106,14 @@ export function UpdateBoardSlugForm({
|
|||||||
<Input
|
<Input
|
||||||
id="board-slug"
|
id="board-slug"
|
||||||
{...register("slug")}
|
{...register("slug")}
|
||||||
errorMessage={errors.slug?.message || (isBoardSlugAvailable?.isReserved
|
errorMessage={errors.slug?.message}
|
||||||
? "This board URL has already been taken"
|
prefix={`kan.bn/${workspaceSlug}/`}
|
||||||
: undefined)}
|
|
||||||
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
|
|
||||||
onKeyDown={async (e) => {
|
onKeyDown={async (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await handleSubmit(onSubmit)();
|
await handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
iconRight={
|
|
||||||
!!errors.slug?.message || isBoardSlugAvailable?.isReserved ? (
|
|
||||||
<HiXMark className="h-4 w-4 text-red-500" />
|
|
||||||
) : (
|
|
||||||
<HiCheck className="h-4 w-4 dark:text-dark-1000" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
@@ -157,8 +124,7 @@ export function UpdateBoardSlugForm({
|
|||||||
disabled={
|
disabled={
|
||||||
!isDirty ||
|
!isDirty ||
|
||||||
updateBoardSlug.isPending ||
|
updateBoardSlug.isPending ||
|
||||||
errors.slug?.message !== undefined ||
|
errors.slug?.message !== undefined
|
||||||
isBoardSlugAvailable?.isReserved
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Update
|
Update
|
||||||
|
|||||||
@@ -240,12 +240,11 @@ export default function BoardPage() {
|
|||||||
<div className="relative flex h-full flex-col">
|
<div className="relative flex h-full flex-col">
|
||||||
<PatternedBackground />
|
<PatternedBackground />
|
||||||
<div className="z-10 flex w-full justify-between p-8">
|
<div className="z-10 flex w-full justify-between p-8">
|
||||||
{isLoading && !boardData && (
|
{isLoading ? (
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
|
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
{boardData && (
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
className="focus-visible:outline-none"
|
className="focus-visible:outline-none"
|
||||||
@@ -259,23 +258,20 @@ export default function BoardPage() {
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
{!boardData && !isLoading && (
|
|
||||||
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">Board not found</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<VisibilityButton
|
<VisibilityButton
|
||||||
visibility={boardData?.visibility ?? "private"}
|
visibility={boardData?.visibility ?? "private"}
|
||||||
boardPublicId={boardId ?? ""}
|
boardPublicId={boardId ?? ""}
|
||||||
queryParams={queryParams}
|
queryParams={queryParams}
|
||||||
isLoading={!boardData}
|
isLoading={isLoading}
|
||||||
isAdmin={workspace.role === "admin"}
|
isAdmin={workspace.role === "admin"}
|
||||||
/>
|
/>
|
||||||
<Filters
|
<Filters
|
||||||
labels={boardData?.labels ?? []}
|
labels={boardData?.labels ?? []}
|
||||||
members={boardData?.workspace.members ?? []}
|
members={boardData?.workspace.members ?? []}
|
||||||
position="left"
|
position="left"
|
||||||
isLoading={!boardData}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
iconLeft={
|
iconLeft={
|
||||||
@@ -287,11 +283,11 @@ export default function BoardPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (boardId) openNewListForm(boardId);
|
if (boardId) openNewListForm(boardId);
|
||||||
}}
|
}}
|
||||||
disabled={!boardData}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
New list
|
New list
|
||||||
</Button>
|
</Button>
|
||||||
<BoardDropdown isLoading={!boardData} />
|
<BoardDropdown isLoading={isLoading} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -302,7 +298,7 @@ export default function BoardPage() {
|
|||||||
<div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
<div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||||
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||||
</div>
|
</div>
|
||||||
) : boardData ? (
|
) : (
|
||||||
<>
|
<>
|
||||||
{boardData?.lists.length === 0 ? (
|
{boardData?.lists.length === 0 ? (
|
||||||
<div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]">
|
<div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]">
|
||||||
@@ -374,12 +370,13 @@ export default function BoardPage() {
|
|||||||
}}
|
}}
|
||||||
key={card.publicId}
|
key={card.publicId}
|
||||||
href={`/cards/${card.publicId}`}
|
href={`/cards/${card.publicId}`}
|
||||||
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||||
"PLACEHOLDER",
|
card.publicId.startsWith(
|
||||||
)
|
"PLACEHOLDER",
|
||||||
? "pointer-events-none"
|
)
|
||||||
: ""
|
? "pointer-events-none"
|
||||||
}`}
|
: ""
|
||||||
|
}`}
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
@@ -407,7 +404,7 @@ export default function BoardPage() {
|
|||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
|
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
|
||||||
{modalContentType === "DELETE_BOARD" && (
|
{modalContentType === "DELETE_BOARD" && (
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
HiOutlineUserMinus,
|
HiOutlineUserMinus,
|
||||||
HiOutlineUserPlus,
|
HiOutlineUserPlus,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import type { GetCardByIdOutput } from "@kan/api/types";
|
import type { GetCardByIdOutput } from "@kan/api/types";
|
||||||
|
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Comment from "./Comment";
|
import Comment from "./Comment";
|
||||||
import { authClient } from "@kan/auth/client";
|
|
||||||
|
|
||||||
type ActivityType =
|
type ActivityType =
|
||||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||||
@@ -147,8 +147,6 @@ const ActivityList = ({
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { data } = authClient.useSession();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-4 pt-4">
|
<div className="flex flex-col space-y-4 pt-4">
|
||||||
{activities.map((activity, index) => {
|
{activities.map((activity, index) => {
|
||||||
@@ -158,7 +156,7 @@ const ActivityList = ({
|
|||||||
fromList: activity.fromList?.name ?? null,
|
fromList: activity.fromList?.name ?? null,
|
||||||
toList: activity.toList?.name ?? null,
|
toList: activity.toList?.name ?? null,
|
||||||
memberName: activity.member?.user?.name ?? null,
|
memberName: activity.member?.user?.name ?? null,
|
||||||
isSelf: activity.member?.user?.id === data?.user?.id,
|
isSelf: activity.member?.user?.id === activity.user?.id,
|
||||||
label: activity.label?.name ?? null,
|
label: activity.label?.name ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,7 +172,7 @@ const ActivityList = ({
|
|||||||
createdAt={activity.createdAt}
|
createdAt={activity.createdAt}
|
||||||
comment={activity.comment?.comment}
|
comment={activity.comment?.comment}
|
||||||
isEdited={!!activity.comment?.updatedAt}
|
isEdited={!!activity.comment?.updatedAt}
|
||||||
isAuthor={activity.comment?.createdBy === data?.user?.id}
|
isAuthor={activity.comment?.createdBy === activity.user?.id}
|
||||||
isAdmin={isAdmin ?? false}
|
isAdmin={isAdmin ?? false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -148,13 +148,12 @@ export default function CardPage() {
|
|||||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||||
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8">
|
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8">
|
||||||
<div className="mb-8 flex w-full items-center justify-between">
|
<div className="mb-8 flex w-full items-center justify-between">
|
||||||
{!card && isLoading && (
|
{isLoading ? (
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
{card && (
|
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
|
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
|
||||||
@@ -185,49 +184,40 @@ export default function CardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!card && !isLoading && (
|
|
||||||
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
|
||||||
Card not found
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{card && (
|
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||||
<>
|
<form
|
||||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
<form
|
className="w-full space-y-6"
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
>
|
||||||
className="w-full space-y-6"
|
<div className="mt-2">
|
||||||
>
|
<ContentEditable
|
||||||
<div className="mt-2">
|
placeholder="Add description..."
|
||||||
<ContentEditable
|
html={watch("description")}
|
||||||
placeholder="Add description..."
|
disabled={false}
|
||||||
html={watch("description")}
|
onChange={(e) => setValue("description", e.target.value)}
|
||||||
disabled={false}
|
onBlur={handleSubmit(onSubmit)}
|
||||||
onChange={(e) => setValue("description", e.target.value)}
|
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||||
onBlur={handleSubmit(onSubmit)}
|
/>
|
||||||
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
|
</form>
|
||||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
</div>
|
||||||
Activity
|
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
|
||||||
</h2>
|
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||||
<div>
|
Activity
|
||||||
<ActivityList
|
</h2>
|
||||||
cardPublicId={cardId}
|
<div>
|
||||||
activities={activities ?? []}
|
<ActivityList
|
||||||
isLoading={!card}
|
cardPublicId={cardId}
|
||||||
isAdmin={workspace.role === "admin"}
|
activities={activities ?? []}
|
||||||
/>
|
isLoading={isLoading}
|
||||||
</div>
|
isAdmin={workspace.role === "admin"}
|
||||||
<div className="mt-6">
|
/>
|
||||||
<NewCommentForm cardPublicId={cardId} />
|
</div>
|
||||||
</div>
|
<div className="mt-6">
|
||||||
</div>
|
<NewCommentForm cardPublicId={cardId} />
|
||||||
</>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[475px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
|
<div className="w-[475px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
|
||||||
@@ -236,7 +226,7 @@ export default function CardPage() {
|
|||||||
<ListSelector
|
<ListSelector
|
||||||
cardPublicId={cardId}
|
cardPublicId={cardId}
|
||||||
lists={formattedLists}
|
lists={formattedLists}
|
||||||
isLoading={!card}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 flex w-full">
|
<div className="mb-4 flex w-full">
|
||||||
@@ -244,7 +234,7 @@ export default function CardPage() {
|
|||||||
<LabelSelector
|
<LabelSelector
|
||||||
cardPublicId={cardId}
|
cardPublicId={cardId}
|
||||||
labels={formattedLabels}
|
labels={formattedLabels}
|
||||||
isLoading={!card}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full">
|
<div className="flex w-full">
|
||||||
@@ -252,7 +242,7 @@ export default function CardPage() {
|
|||||||
<MemberSelector
|
<MemberSelector
|
||||||
cardPublicId={cardId}
|
cardPublicId={cardId}
|
||||||
members={formattedMembers}
|
members={formattedMembers}
|
||||||
isLoading={!card}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
import { useTheme } from "~/providers/theme";
|
import { useTheme } from "~/providers/theme";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
import Footer from "./Footer";
|
import Footer from "./Footer";
|
||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
import { authClient } from "@kan/auth/client";
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const { data: session } = authClient.useSession();
|
const token =
|
||||||
|
typeof window !== "undefined" ? Cookies.get("kan.session_token") : null;
|
||||||
|
|
||||||
const isLoggedIn = !!session?.user;
|
const { data } = api.user.getUser.useQuery(undefined, {
|
||||||
|
enabled: !!token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoggedIn = !!data;
|
||||||
|
|
||||||
const isDarkMode = theme.activeTheme === "dark";
|
const isDarkMode = theme.activeTheme === "dark";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
|
import { IoLogoGithub } from "react-icons/io";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
@@ -21,41 +21,18 @@ export default function HomeView() {
|
|||||||
<div className="flex h-full w-full flex-col lg:pt-[5rem]">
|
<div className="flex h-full w-full flex-col lg:pt-[5rem]">
|
||||||
<div className="w-full pb-10 pt-32 lg:py-32">
|
<div className="w-full pb-10 pt-32 lg:py-32">
|
||||||
<div className="my-10 flex h-full w-full animate-fade-down flex-col items-center justify-center px-4">
|
<div className="my-10 flex h-full w-full animate-fade-down flex-col items-center justify-center px-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative animate-fade-in overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
|
||||||
<div className="relative animate-fade-in overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
|
<div className="gradient-border absolute inset-0 animate-border-spin" />
|
||||||
<div className="gradient-border absolute inset-0 animate-border-spin" />
|
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
||||||
|
<Link
|
||||||
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
href="https://github.com/kanbn/kan"
|
||||||
<Link
|
rel="noopener noreferrer"
|
||||||
href="https://github.com/kanbn/kan"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
|
||||||
target="_blank"
|
>
|
||||||
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
|
Star on Github
|
||||||
>
|
<IoLogoGithub size={20} />
|
||||||
Star on Github
|
</Link>
|
||||||
<IoLogoGithub size={20} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
|
|
||||||
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
|
||||||
<Link
|
|
||||||
href="https://news.ycombinator.com/item?id=44157177"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
|
|
||||||
>
|
|
||||||
#1 Hacker News
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-1 bg-white" />
|
|
||||||
<IoLogoHackernews
|
|
||||||
size={20}
|
|
||||||
className="relative text-orange-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
|
|||||||
@@ -15,19 +15,14 @@ export function DeleteWorkspaceConfirmation() {
|
|||||||
|
|
||||||
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
|
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
|
||||||
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const deleteWorkspaceMutation = api.workspace.delete.useMutation({
|
const deleteWorkspaceMutation = api.workspace.delete.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
showPopup({
|
showPopup({
|
||||||
header: "Workspace deleted",
|
header: "Workspace deleted",
|
||||||
message: "Your workspace has been deleted.",
|
message: "Your workspace has been deleted.",
|
||||||
icon: "success",
|
icon: "success",
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.workspace.all.refetch();
|
|
||||||
|
|
||||||
const filteredWorkspaces = availableWorkspaces.filter(
|
const filteredWorkspaces = availableWorkspaces.filter(
|
||||||
(ws) => ws.publicId !== workspace.publicId,
|
(ws) => ws.publicId !== workspace.publicId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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";
|
||||||
@@ -93,9 +92,7 @@ const UpdateWorkspaceUrlForm = ({
|
|||||||
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
|
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
|
||||||
|
|
||||||
const onSubmit = (data: FormValues) => {
|
const onSubmit = (data: FormValues) => {
|
||||||
if (!isWorkspaceSlugAvailable?.isAvailable) return;
|
if (isWorkspaceSlugAvailable?.isAvailable && workspacePlan !== "pro")
|
||||||
|
|
||||||
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({
|
||||||
@@ -121,11 +118,7 @@ const UpdateWorkspaceUrlForm = ({
|
|||||||
? "This workspace username has already been taken"
|
? "This workspace username has already been taken"
|
||||||
: undefined)
|
: undefined)
|
||||||
}
|
}
|
||||||
prefix={
|
prefix="kan.bn/"
|
||||||
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) ? (
|
||||||
|
|||||||
@@ -15,10 +15,8 @@ services:
|
|||||||
command: ["pnpm", "start"]
|
command: ["pnpm", "start"]
|
||||||
environment:
|
environment:
|
||||||
- EMAIL_FROM=${EMAIL_FROM}
|
- EMAIL_FROM=${EMAIL_FROM}
|
||||||
- SMTP_HOST=${SMTP_HOST}
|
- EMAIL_URL=${EMAIL_URL}
|
||||||
- SMTP_PORT=${SMTP_PORT}
|
- EMAIL_TOKEN=${EMAIL_TOKEN}
|
||||||
- SMTP_USER=${SMTP_USER}
|
|
||||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
|
||||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL}
|
- BETTER_AUTH_URL=${BETTER_AUTH_URL}
|
||||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
||||||
@@ -27,41 +25,6 @@ services:
|
|||||||
- POSTGRES_URL=${POSTGRES_URL}
|
- POSTGRES_URL=${POSTGRES_URL}
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
|
||||||
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
|
||||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
|
||||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
|
|
||||||
- GITLAB_CLIENT_ID=${GITLAB_CLIENT_ID}
|
|
||||||
- GITLAB_CLIENT_SECRET=${GITLAB_CLIENT_SECRET}
|
|
||||||
- GITLAB_ISSUER=${GITLAB_ISSUER}
|
|
||||||
- MICROSOFT_CLIENT_ID=${MICROSOFT_CLIENT_ID}
|
|
||||||
- MICROSOFT_CLIENT_SECRET=${MICROSOFT_CLIENT_SECRET}
|
|
||||||
- TWITTER_CLIENT_ID=${TWITTER_CLIENT_ID}
|
|
||||||
- TWITTER_CLIENT_SECRET=${TWITTER_CLIENT_SECRET}
|
|
||||||
- KICK_CLIENT_ID=${KICK_CLIENT_ID}
|
|
||||||
- KICK_CLIENT_SECRET=${KICK_CLIENT_SECRET}
|
|
||||||
- ZOOM_CLIENT_ID=${ZOOM_CLIENT_ID}
|
|
||||||
- ZOOM_CLIENT_SECRET=${ZOOM_CLIENT_SECRET}
|
|
||||||
- DROPBOX_CLIENT_ID=${DROPBOX_CLIENT_ID}
|
|
||||||
- DROPBOX_CLIENT_SECRET=${DROPBOX_CLIENT_SECRET}
|
|
||||||
- VK_CLIENT_ID=${VK_CLIENT_ID}
|
|
||||||
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
|
|
||||||
- LINKEDIN_CLIENT_ID=${LINKEDIN_CLIENT_ID}
|
|
||||||
- LINKEDIN_CLIENT_SECRET=${LINKEDIN_CLIENT_SECRET}
|
|
||||||
- REDDIT_CLIENT_ID=${REDDIT_CLIENT_ID}
|
|
||||||
- REDDIT_CLIENT_SECRET=${REDDIT_CLIENT_SECRET}
|
|
||||||
- ROBLOX_CLIENT_ID=${ROBLOX_CLIENT_ID}
|
|
||||||
- ROBLOX_CLIENT_SECRET=${ROBLOX_CLIENT_SECRET}
|
|
||||||
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
|
|
||||||
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
|
|
||||||
- TIKTOK_CLIENT_ID=${TIKTOK_CLIENT_ID}
|
|
||||||
- TIKTOK_CLIENT_SECRET=${TIKTOK_CLIENT_SECRET}
|
|
||||||
- TIKTOK_CLIENT_KEY=${TIKTOK_CLIENT_KEY}
|
|
||||||
- TWITCH_CLIENT_ID=${TWITCH_CLIENT_ID}
|
|
||||||
- TWITCH_CLIENT_SECRET=${TWITCH_CLIENT_SECRET}
|
|
||||||
- APPLE_CLIENT_ID=${APPLE_CLIENT_ID}
|
|
||||||
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
|
|
||||||
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
|
|
||||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
- S3_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}
|
||||||
|
|||||||
@@ -352,40 +352,4 @@ export const boardRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
checkSlugAvailability: publicProcedure
|
|
||||||
.meta({
|
|
||||||
openapi: {
|
|
||||||
summary: "Check if a board slug is available",
|
|
||||||
method: "GET",
|
|
||||||
path: "/boards/{boardPublicId}/check-slug-availability",
|
|
||||||
description: "Checks if a board slug is available",
|
|
||||||
tags: ["Boards"],
|
|
||||||
protect: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
boardSlug: z
|
|
||||||
.string()
|
|
||||||
.min(3)
|
|
||||||
.max(24)
|
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
|
||||||
boardPublicId: z.string().min(12),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(
|
|
||||||
z.object({
|
|
||||||
isReserved: z.boolean(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
|
||||||
ctx.db,
|
|
||||||
input.boardSlug,
|
|
||||||
input.boardPublicId,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
isReserved: !isBoardSlugAvailable,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -578,6 +578,14 @@ 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,
|
||||||
@@ -589,17 +597,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (card.workspaceVisibility === "private") {
|
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||||
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,
|
||||||
|
|||||||
@@ -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/client";
|
import { authClient } from "@kan/auth";
|
||||||
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,5 +1,4 @@
|
|||||||
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";
|
||||||
@@ -225,19 +224,10 @@ 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/server";
|
import { initAuth } from "@kan/auth";
|
||||||
import { createDrizzleClient } from "@kan/db/client";
|
import { createDrizzleClient } from "@kan/db/client";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
|||||||
@@ -4,15 +4,7 @@
|
|||||||
"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,10 +1,9 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
import { createAuthMiddleware } from "better-auth/api";
|
||||||
import { apiKey } from "better-auth/plugins";
|
import { apiKey } from "better-auth/plugins";
|
||||||
import { magicLink } from "better-auth/plugins/magic-link";
|
import { magicLink } from "better-auth/plugins/magic-link";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
@@ -12,89 +11,6 @@ import * as userRepo from "@kan/db/repository/user.repo";
|
|||||||
import * as schema from "@kan/db/schema";
|
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";
|
||||||
import { socialProviderList } from "better-auth/social-providers";
|
|
||||||
|
|
||||||
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({
|
||||||
@@ -110,7 +26,12 @@ export const initAuth = (db: dbClient) => {
|
|||||||
user: schema.users,
|
user: schema.users,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
socialProviders: configuredProviders,
|
socialProviders: {
|
||||||
|
google: {
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||||
|
},
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
additionalFields: {
|
additionalFields: {
|
||||||
stripeCustomerId: {
|
stripeCustomerId: {
|
||||||
@@ -122,7 +43,6 @@ export const initAuth = (db: dbClient) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
socialProvidersPlugin(),
|
|
||||||
// @todo: hasing is disabled due to a bug in the api key plugin
|
// @todo: hasing is disabled due to a bug in the api key plugin
|
||||||
apiKey({ disableKeyHashing: true }),
|
apiKey({ disableKeyHashing: true }),
|
||||||
magicLink({
|
magicLink({
|
||||||
@@ -145,46 +65,6 @@ export const initAuth = (db: dbClient) => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
databaseHooks: {
|
|
||||||
user: {
|
|
||||||
create: {
|
|
||||||
async after(user, _context) {
|
|
||||||
if (user.image && !user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)) {
|
|
||||||
try {
|
|
||||||
const client = new S3Client({
|
|
||||||
region: env("S3_REGION") ?? "",
|
|
||||||
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")) {
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
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],
|
|
||||||
});
|
|
||||||
6
packages/auth/src/clients.ts
Normal file
6
packages/auth/src/clients.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins";
|
||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
plugins: [magicLinkClient(), apiKeyClient()],
|
||||||
|
});
|
||||||
@@ -1 +1,6 @@
|
|||||||
|
import { initAuth } from "./auth";
|
||||||
|
import { authClient } from "./clients";
|
||||||
|
|
||||||
export const name = "auth";
|
export const name = "auth";
|
||||||
|
|
||||||
|
export { initAuth, authClient };
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { initAuth } from "./auth";
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "@kan/tsconfig/internal-package.json",
|
"extends": "@kan/tsconfig/internal-package.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {},
|
||||||
"jsx": "react-jsx"
|
|
||||||
},
|
|
||||||
"include": ["*.ts", "src"],
|
"include": ["*.ts", "src"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_fromListId_list_id_fk";
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_toListId_list_id_fk";
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_labelId_label_id_fk";
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_workspaceMemberId_workspace_members_id_fk";
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_createdBy_user_id_fk";
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "card_activity" DROP CONSTRAINT "card_activity_commentId_card_comments_id_fk";
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "_card_workspace_members" DROP CONSTRAINT "_card_workspace_members_cardId_card_id_fk";
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "_card_labels" DROP CONSTRAINT "_card_labels_cardId_card_id_fk";
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "card_comments" DROP CONSTRAINT "card_comments_createdBy_user_id_fk";
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "card_comments" DROP CONSTRAINT "card_comments_deletedBy_user_id_fk";
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_fromListId_list_id_fk" FOREIGN KEY ("fromListId") REFERENCES "public"."list"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_toListId_list_id_fk" FOREIGN KEY ("toListId") REFERENCES "public"."list"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_labelId_label_id_fk" FOREIGN KEY ("labelId") REFERENCES "public"."label"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_workspaceMemberId_workspace_members_id_fk" FOREIGN KEY ("workspaceMemberId") REFERENCES "public"."workspace_members"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_commentId_card_comments_id_fk" FOREIGN KEY ("commentId") REFERENCES "public"."card_comments"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "_card_workspace_members" ADD CONSTRAINT "_card_workspace_members_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "_card_labels" ADD CONSTRAINT "_card_labels_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -22,13 +22,6 @@
|
|||||||
"when": 1748378293342,
|
"when": 1748378293342,
|
||||||
"tag": "20250527203813_AddDeletedAtToLabel",
|
"tag": "20250527203813_AddDeletedAtToLabel",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 3,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1749118315675,
|
|
||||||
"tag": "20250605101155_AddCascadeDeleteToCardRelations",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, asc, desc, eq, exists, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import type { BoardVisibilityStatus } from "@kan/db/schema";
|
import type { BoardVisibilityStatus } from "@kan/db/schema";
|
||||||
@@ -239,7 +239,6 @@ export const getBySlug = async (
|
|||||||
workspace: {
|
workspace: {
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
name: true,
|
|
||||||
slug: true,
|
slug: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -477,34 +476,3 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isBoardSlugAvailable = async (
|
|
||||||
db: dbClient,
|
|
||||||
boardSlug: string,
|
|
||||||
boardPublicId: string,
|
|
||||||
) => {
|
|
||||||
const result = await db
|
|
||||||
.select({ id: boards.id })
|
|
||||||
.from(boards)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(boards.publicId, boardPublicId),
|
|
||||||
exists(
|
|
||||||
db
|
|
||||||
.select({ id: boards.id })
|
|
||||||
.from(boards)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(boards.slug, boardSlug),
|
|
||||||
eq(boards.workspaceId, sql`${boards.workspaceId}`), // Reference outer query's workspaceId
|
|
||||||
isNull(boards.deletedAt),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return result.length === 0;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -731,7 +731,6 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
|||||||
board: {
|
board: {
|
||||||
columns: {
|
columns: {
|
||||||
workspaceId: true,
|
workspaceId: true,
|
||||||
visibility: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -743,7 +742,6 @@ 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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,23 +90,23 @@ export const cardActivities = pgTable("card_activity", {
|
|||||||
fromIndex: integer("fromIndex"),
|
fromIndex: integer("fromIndex"),
|
||||||
toIndex: integer("toIndex"),
|
toIndex: integer("toIndex"),
|
||||||
fromListId: bigint("fromListId", { mode: "number" }).references(
|
fromListId: bigint("fromListId", { mode: "number" }).references(
|
||||||
() => lists.id, { onDelete: "cascade" },
|
() => lists.id,
|
||||||
),
|
),
|
||||||
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id, { onDelete: "cascade" }),
|
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),
|
||||||
workspaceMemberId: bigint("workspaceMemberId", {
|
workspaceMemberId: bigint("workspaceMemberId", {
|
||||||
mode: "number",
|
mode: "number",
|
||||||
}).references(() => workspaceMembers.id, { onDelete: "cascade" }),
|
}).references(() => workspaceMembers.id),
|
||||||
fromTitle: varchar("fromTitle", { length: 255 }),
|
fromTitle: varchar("fromTitle", { length: 255 }),
|
||||||
toTitle: varchar("toTitle", { length: 255 }),
|
toTitle: varchar("toTitle", { length: 255 }),
|
||||||
fromDescription: text("fromDescription"),
|
fromDescription: text("fromDescription"),
|
||||||
toDescription: text("toDescription"),
|
toDescription: text("toDescription"),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.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, { onDelete: "cascade" },
|
() => comments.id,
|
||||||
),
|
),
|
||||||
fromComment: text("fromComment"),
|
fromComment: text("fromComment"),
|
||||||
toComment: text("toComment"),
|
toComment: text("toComment"),
|
||||||
@@ -152,7 +152,7 @@ export const cardsToLabels = pgTable(
|
|||||||
{
|
{
|
||||||
cardId: bigint("cardId", { mode: "number" })
|
cardId: bigint("cardId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => cards.id, { onDelete: "cascade" }),
|
.references(() => cards.id),
|
||||||
labelId: bigint("labelId", { mode: "number" })
|
labelId: bigint("labelId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => labels.id, { onDelete: "cascade" }),
|
.references(() => labels.id, { onDelete: "cascade" }),
|
||||||
@@ -176,7 +176,7 @@ export const cardToWorkspaceMembers = pgTable(
|
|||||||
{
|
{
|
||||||
cardId: bigint("cardId", { mode: "number" })
|
cardId: bigint("cardId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => cards.id, { onDelete: "cascade" }),
|
.references(() => cards.id),
|
||||||
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" })
|
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => workspaceMembers.id, { onDelete: "cascade" }),
|
.references(() => workspaceMembers.id, { onDelete: "cascade" }),
|
||||||
@@ -207,11 +207,11 @@ export const comments = pgTable("card_comments", {
|
|||||||
.references(() => cards.id, { onDelete: "cascade" }),
|
.references(() => cards.id, { onDelete: "cascade" }),
|
||||||
createdBy: uuid("createdBy")
|
createdBy: uuid("createdBy")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.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, { onDelete: "cascade" }),
|
deletedBy: uuid("deletedBy").references(() => users.id),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
export const commentsRelations = relations(comments, ({ one }) => ({
|
export const commentsRelations = relations(comments, ({ one }) => ({
|
||||||
|
|||||||
@@ -24,14 +24,12 @@
|
|||||||
},
|
},
|
||||||
"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,5 +1,4 @@
|
|||||||
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";
|
||||||
@@ -11,16 +10,6 @@ const emailTemplates: Record<Templates, React.FC> = {
|
|||||||
JOIN_WORKSPACE: JoinWorkspaceTemplate,
|
JOIN_WORKSPACE: JoinWorkspaceTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
@@ -31,17 +20,22 @@ export const sendEmail = async (
|
|||||||
|
|
||||||
const html = await render(<EmailTemplate {...data} />, { pretty: true });
|
const html = await render(<EmailTemplate {...data} />, { pretty: true });
|
||||||
|
|
||||||
const options = {
|
const response = await fetch(process.env.EMAIL_URL!, {
|
||||||
from: process.env.EMAIL_FROM,
|
method: "POST",
|
||||||
to,
|
headers: {
|
||||||
subject,
|
"Content-Type": "application/json",
|
||||||
html,
|
Authorization: `Bearer ${process.env.EMAIL_TOKEN}`,
|
||||||
};
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: process.env.EMAIL_FROM,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const response = await transporter.sendMail(options);
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to send email: ${response.statusText}`);
|
||||||
if (!response.accepted.length) {
|
|
||||||
throw new Error(`Failed to send email: ${response.response}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@@ -126,6 +126,12 @@ importers:
|
|||||||
geist:
|
geist:
|
||||||
specifier: ^1.3.1
|
specifier: ^1.3.1
|
||||||
version: 1.3.1(next@14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
|
version: 1.3.1(next@14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
|
||||||
|
js-cookie:
|
||||||
|
specifier: ^3.0.5
|
||||||
|
version: 3.0.5
|
||||||
|
jwt-decode:
|
||||||
|
specifier: ^4.0.0
|
||||||
|
version: 4.0.0
|
||||||
next:
|
next:
|
||||||
specifier: ^14.2.15
|
specifier: ^14.2.15
|
||||||
version: 14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 14.2.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -187,6 +193,9 @@ importers:
|
|||||||
'@kan/tsconfig':
|
'@kan/tsconfig':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../tooling/typescript
|
version: link:../../tooling/typescript
|
||||||
|
'@types/js-cookie':
|
||||||
|
specifier: ^3.0.6
|
||||||
|
version: 3.0.6
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20.17.7
|
specifier: ^20.17.7
|
||||||
version: 20.17.9
|
version: 20.17.9
|
||||||
@@ -361,9 +370,6 @@ importers:
|
|||||||
'@react-email/components':
|
'@react-email/components':
|
||||||
specifier: ^0.0.25
|
specifier: ^0.0.25
|
||||||
version: 0.0.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 0.0.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
nodemailer:
|
|
||||||
specifier: ^7.0.3
|
|
||||||
version: 7.0.3
|
|
||||||
react-email:
|
react-email:
|
||||||
specifier: ^3.0.1
|
specifier: ^3.0.1
|
||||||
version: 3.0.4(bufferutil@4.0.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(utf-8-validate@6.0.3)
|
version: 3.0.4(bufferutil@4.0.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(utf-8-validate@6.0.3)
|
||||||
@@ -377,9 +383,6 @@ importers:
|
|||||||
'@kan/tsconfig':
|
'@kan/tsconfig':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../tooling/typescript
|
version: link:../../tooling/typescript
|
||||||
'@types/nodemailer':
|
|
||||||
specifier: ^6.4.17
|
|
||||||
version: 6.4.17
|
|
||||||
eslint:
|
eslint:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 9.16.0(jiti@1.21.6)
|
version: 9.16.0(jiti@1.21.6)
|
||||||
@@ -2315,6 +2318,9 @@ packages:
|
|||||||
'@types/inquirer@6.5.0':
|
'@types/inquirer@6.5.0':
|
||||||
resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==}
|
resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==}
|
||||||
|
|
||||||
|
'@types/js-cookie@3.0.6':
|
||||||
|
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
@@ -2336,9 +2342,6 @@ packages:
|
|||||||
'@types/node@20.17.9':
|
'@types/node@20.17.9':
|
||||||
resolution: {integrity: sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==}
|
resolution: {integrity: sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==}
|
||||||
|
|
||||||
'@types/nodemailer@6.4.17':
|
|
||||||
resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==}
|
|
||||||
|
|
||||||
'@types/pg@8.11.6':
|
'@types/pg@8.11.6':
|
||||||
resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==}
|
resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==}
|
||||||
|
|
||||||
@@ -4054,6 +4057,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
|
jwt-decode@4.0.0:
|
||||||
|
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
katex@0.16.19:
|
katex@0.16.19:
|
||||||
resolution: {integrity: sha512-3IA6DYVhxhBabjSLTNO9S4+OliA3Qvb8pBQXMfC4WxXJgLwZgnfDl0BmB4z6nBMdznBsZ+CGM8DrGZ5hcguDZg==}
|
resolution: {integrity: sha512-3IA6DYVhxhBabjSLTNO9S4+OliA3Qvb8pBQXMfC4WxXJgLwZgnfDl0BmB4z6nBMdznBsZ+CGM8DrGZ5hcguDZg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -4530,10 +4537,6 @@ packages:
|
|||||||
node-releases@2.0.19:
|
node-releases@2.0.19:
|
||||||
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
||||||
|
|
||||||
nodemailer@7.0.3:
|
|
||||||
resolution: {integrity: sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==}
|
|
||||||
engines: {node: '>=6.0.0'}
|
|
||||||
|
|
||||||
nopt@7.2.1:
|
nopt@7.2.1:
|
||||||
resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==}
|
resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==}
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||||
@@ -8072,6 +8075,8 @@ snapshots:
|
|||||||
'@types/through': 0.0.33
|
'@types/through': 0.0.33
|
||||||
rxjs: 6.6.7
|
rxjs: 6.6.7
|
||||||
|
|
||||||
|
'@types/js-cookie@3.0.6': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
'@types/json5@0.0.29': {}
|
'@types/json5@0.0.29': {}
|
||||||
@@ -8090,10 +8095,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.19.8
|
undici-types: 6.19.8
|
||||||
|
|
||||||
'@types/nodemailer@6.4.17':
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 20.17.9
|
|
||||||
|
|
||||||
'@types/pg@8.11.6':
|
'@types/pg@8.11.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.17.9
|
'@types/node': 20.17.9
|
||||||
@@ -10056,6 +10057,8 @@ snapshots:
|
|||||||
object.assign: 4.1.5
|
object.assign: 4.1.5
|
||||||
object.values: 1.2.0
|
object.values: 1.2.0
|
||||||
|
|
||||||
|
jwt-decode@4.0.0: {}
|
||||||
|
|
||||||
katex@0.16.19:
|
katex@0.16.19:
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 8.3.0
|
commander: 8.3.0
|
||||||
@@ -10756,8 +10759,6 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.19: {}
|
node-releases@2.0.19: {}
|
||||||
|
|
||||||
nodemailer@7.0.3: {}
|
|
||||||
|
|
||||||
nopt@7.2.1:
|
nopt@7.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
abbrev: 2.0.0
|
abbrev: 2.0.0
|
||||||
|
|||||||
82
turbo.json
82
turbo.json
@@ -3,49 +3,28 @@
|
|||||||
"ui": "tui",
|
"ui": "tui",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"topo": {
|
"topo": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^topo"]
|
||||||
"^topo"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^build"],
|
||||||
"^build"
|
"outputs": [".cache/tsbuildinfo.json", "dist/**"]
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
".cache/tsbuildinfo.json",
|
|
||||||
"dist/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^dev"],
|
||||||
"^dev"
|
|
||||||
],
|
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": false
|
"persistent": false
|
||||||
},
|
},
|
||||||
"format": {
|
"format": {
|
||||||
"outputs": [
|
"outputs": [".cache/.prettiercache"],
|
||||||
".cache/.prettiercache"
|
|
||||||
],
|
|
||||||
"outputLogs": "new-only"
|
"outputLogs": "new-only"
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^topo", "^build"],
|
||||||
"^topo",
|
"outputs": [".cache/.eslintcache"]
|
||||||
"^build"
|
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
".cache/.eslintcache"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^topo", "^build"],
|
||||||
"^topo",
|
"outputs": [".cache/tsbuildinfo.json"]
|
||||||
"^build"
|
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
".cache/tsbuildinfo.json"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"clean": {
|
"clean": {
|
||||||
"cache": false
|
"cache": false
|
||||||
@@ -70,46 +49,9 @@
|
|||||||
"POSTGRES_URL",
|
"POSTGRES_URL",
|
||||||
"GOOGLE_CLIENT_ID",
|
"GOOGLE_CLIENT_ID",
|
||||||
"GOOGLE_CLIENT_SECRET",
|
"GOOGLE_CLIENT_SECRET",
|
||||||
"DISCORD_CLIENT_ID",
|
|
||||||
"DISCORD_CLIENT_SECRET",
|
|
||||||
"GITHUB_CLIENT_ID",
|
|
||||||
"GITHUB_CLIENT_SECRET",
|
|
||||||
"GITLAB_CLIENT_ID",
|
|
||||||
"GITLAB_CLIENT_SECRET",
|
|
||||||
"GITLAB_ISSUER",
|
|
||||||
"MICROSOFT_CLIENT_ID",
|
|
||||||
"MICROSOFT_CLIENT_SECRET",
|
|
||||||
"TWITTER_CLIENT_ID",
|
|
||||||
"TWITTER_CLIENT_SECRET",
|
|
||||||
"KICK_CLIENT_ID",
|
|
||||||
"KICK_CLIENT_SECRET",
|
|
||||||
"ZOOM_CLIENT_ID",
|
|
||||||
"ZOOM_CLIENT_SECRET",
|
|
||||||
"DROPBOX_CLIENT_ID",
|
|
||||||
"DROPBOX_CLIENT_SECRET",
|
|
||||||
"VK_CLIENT_ID",
|
|
||||||
"VK_CLIENT_SECRET",
|
|
||||||
"LINKEDIN_CLIENT_ID",
|
|
||||||
"LINKEDIN_CLIENT_SECRET",
|
|
||||||
"REDDIT_CLIENT_ID",
|
|
||||||
"REDDIT_CLIENT_SECRET",
|
|
||||||
"ROBLOX_CLIENT_ID",
|
|
||||||
"ROBLOX_CLIENT_SECRET",
|
|
||||||
"SPOTIFY_CLIENT_ID",
|
|
||||||
"SPOTIFY_CLIENT_SECRET",
|
|
||||||
"TIKTOK_CLIENT_ID",
|
|
||||||
"TIKTOK_CLIENT_SECRET",
|
|
||||||
"TIKTOK_CLIENT_KEY",
|
|
||||||
"TWITCH_CLIENT_ID",
|
|
||||||
"TWITCH_CLIENT_SECRET",
|
|
||||||
"APPLE_CLIENT_ID",
|
|
||||||
"APPLE_CLIENT_SECRET",
|
|
||||||
"APPLE_APP_BUNDLE_IDENTIFIER",
|
|
||||||
"EMAIL_FROM",
|
"EMAIL_FROM",
|
||||||
"SMTP_HOST",
|
"EMAIL_URL",
|
||||||
"SMTP_PORT",
|
"EMAIL_TOKEN",
|
||||||
"SMTP_USER",
|
|
||||||
"SMTP_PASSWORD",
|
|
||||||
"NEXT_PUBLIC_KAN_ENV",
|
"NEXT_PUBLIC_KAN_ENV",
|
||||||
"STRIPE_SECRET_KEY",
|
"STRIPE_SECRET_KEY",
|
||||||
"STRIPE_WEBHOOK_SECRET",
|
"STRIPE_WEBHOOK_SECRET",
|
||||||
@@ -134,4 +76,4 @@
|
|||||||
"VERCEL_URL",
|
"VERCEL_URL",
|
||||||
"npm_lifecycle_event"
|
"npm_lifecycle_event"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user