Compare commits

..

66 Commits

Author SHA1 Message Date
Henry
641237de83 feat: add url validation to download attatchment endpoint 2026-03-10 21:54:35 +00:00
Henry
a6780e3e28 fix: show correct highlighting in pricing comparison table 2026-03-03 22:31:08 +00:00
hjball
2c5195a2b8 chore: update translations 2026-03-03 22:17:28 +00:00
Henry
793fa30162 fix: adjust icon size in CardPage component 2026-03-03 22:16:09 +00:00
Henry
af63719de5 fix: swap latest to only publish on version tags and add edge for builds (#428) 2026-03-03 22:14:35 +00:00
hjball
52fd624b81 chore: update translations 2026-03-03 21:37:46 +00:00
Henry
b4d4810f45 fix: migration order (#427) 2026-03-03 21:36:31 +00:00
hjball
239d152340 chore: compile translations 2026-03-01 22:41:43 +00:00
hjball
f294760747 chore: update translations 2026-03-01 22:41:39 +00:00
Morfixx
280d8f66dd feat: initial github integration with importing projects (#421)
* feat: initial github integration with importing projects

* fix: remove unused args

* chore: remove duplicate col

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-03-01 22:39:25 +00:00
Henry
eeae23a24c docs: add migrate service to self hosting guide (#425)
* docs: add migrate service to self hosting guide

* chore: remove build from readme compose
2026-02-28 23:08:59 +00:00
Henry
d81950b8bd fix: publish migrate image (#423) 2026-02-27 22:38:01 +00:00
hjball
e530f39360 chore: update translations 2026-02-27 13:16:47 +00:00
Nick Meinhold
bd25fb33f7 feat(api): add webhook delivery utility and card event integration (#392)
* feat(api): add webhook delivery utility and card event integration

Add the core webhook delivery logic and wire it into card mutations:

- Add sendWebhookToUrl() with HMAC-SHA256 signing, 10s timeout
- Add sendWebhooksForWorkspace() for fan-out delivery (fire-and-forget)
- Add createCardWebhookPayload() for building webhook payloads
- Fire webhooks on card create, update, move, and delete events
- Add unit tests for webhook utility functions

Depends on #391 (DB schema & repository).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): use correct boardId in webhook payloads and add rejection safety

- Fix bug where workspaceId was incorrectly passed as boardId in all
  webhook payloads — now uses board's publicId via boardPublicId
- Replace void sendWebhooksForWorkspace() with .catch() to prevent
  unhandled promise rejections if the DB query inside fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): add SSRF protection to webhook delivery

Block webhook URLs targeting internal networks:
- Require HTTPS (reject HTTP)
- Block localhost, 127.0.0.1, ::1, 0.0.0.0
- Block cloud metadata endpoints (169.254.169.254, metadata.google.internal)
- Block private IP ranges (10.x, 172.16-31.x, 192.168.x)
- Add tests for all blocked URL patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): use WebhookEvent type from schema instead of duplicating

Replace the hardcoded WebhookEventType union with the canonical
WebhookEvent type from @kan/db/schema, addressing reviewer feedback
on PR #392.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): improve webhook delivery safety and validation

Cherry-pick delivery-related changes from b2cc9ac:
- Extract URL validation into reusable webhookUrlSchema zod validator
  for SSRF checks
- Wrap sendWebhooksForWorkspace in try/catch to prevent unhandled
  promise rejections
- Document SSRF risk mitigation on sendWebhookToUrl
- Add corresponding tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:15:35 +00:00
hjball
93f2816b37 chore: compile translations 2026-02-26 21:41:09 +00:00
hjball
2292b972c4 chore: update translations 2026-02-26 21:41:05 +00:00
Eliott Herbert-Byrnes
af36fb133a feat: archived boards (#386)
* init: schema migration, isArchived added to
board table. refactor: board repo for isArchived filtering

* init: archived, unarchived API procedures. refactor: all query

* fix: migration error

* feat: add tabbed navigation for boards view

* Implemented a Listbox for mobile and a tabbed navigation for desktop to switch between "Boards" and "Archived" views.
* Introduced state management for active tab selection.
* Updated UI components to reflect the new navigation structure.

* init: frontend/boards lists & tabs

* chore: fixed font styling and spacing

* init:boardDropdown / boardView.

* chore:added translations

* Remove .cursor plan file from repo

* fix:build erros

* revert: remove locales changes

* fix:reverted changes under locales, replaced the archive and unarchive endpoints. Reorder migrations

* fix:migration issue

* fix: update journal.json

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-02-26 21:39:15 +00:00
hjball
9cb1fb5218 chore: update translations 2026-02-26 21:26:17 +00:00
Nick Meinhold
5f190b92de fix: allow invited users to sign up when registration is disabled (#418)
* fix: allow invited users to sign up when registration is disabled

Move sign-up restriction logic from better-auth's disableSignUp config
to the existing user.create.before database hook, which already checks
for pending invitations. The frontend signup and login pages now detect
invite flows (?next=/invite/...) and bypass the disabled UI accordingly.

Closes #411

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add regression tests for sign-up hook invite bypass

Verify that the user.create.before database hook correctly:
- allows sign-up when registration is not disabled
- blocks sign-up when disabled and no invitation exists
- allows sign-up when disabled but a pending invitation exists
- respects BETTER_AUTH_ALLOWED_DOMAINS in combination with invites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add OIDC/social sign-up path coverage for invite bypass

Address review suggestion: add explicit tests verifying the
user.create.before hook handles OIDC/social sign-ups the same way as
email/password — invited users are allowed, uninvited users are blocked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:24:58 +00:00
Nick Meinhold
48a1f39588 fix(api): add missing openapi meta to checklist.update (#417)
The checklist.update procedure was inheriting the default
protectedProcedure meta (GET /protected) instead of declaring
its own OpenAPI route, making it unreachable via the REST API.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:12:02 +00:00
hjball
c7fce10fa7 chore: update translations 2026-02-26 21:05:45 +00:00
Matej
094e1d139d fix: make links clickable in comments (#376) (#415)
Move Link extension before Markdown in the TipTap extensions array
to ensure URL detection happens before markdown processing. Also
explicitly enable linkOnPaste for paste detection.

Fixes #376

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 21:04:31 +00:00
Henry
fae169c691 fix: ensure deps are installed during translate workflow 2026-02-23 23:11:52 +00:00
Henry
91bc319cf1 fix: prevent cross-workspace access on member and permission endpoints (#413) 2026-02-23 22:56:36 +00:00
Henry
d42540b9ef chore: update translations to json (#408)
* chore: update translations format

* chore: compile to ts
2026-02-20 21:38:01 +00:00
Henry
71ecf2e6d3 fix: catch and log failed email attempts 2026-02-20 15:42:07 +00:00
Henry
8a9cb8fabf chore: trigger docker publish after translate workflow 2026-02-19 22:30:19 +00:00
Henry
37f6be52ee chore: improve translation workflow 2026-02-19 22:26:20 +00:00
hjball
b72449bef1 chore: compile translations 2026-02-19 21:39:32 +00:00
Henry
5cb47a588f fix: update file pattern for translation workflow 2026-02-19 21:38:25 +00:00
Lingo.dev
d5c2404715 chore: update translations 2026-02-19 21:23:58 +00:00
Henry
0276c51ac7 fix: correct broken workflow 2026-02-19 21:22:58 +00:00
Henry
1eee819c9d fix: typo 2026-02-19 21:20:40 +00:00
Henry
2113cc7873 fix: update translate workflow 2026-02-19 21:19:46 +00:00
Henry
32714ccedf fix: update commit message format in translation workflow 2026-02-19 21:12:41 +00:00
Henry
e72b34b541 feat: setup translation workflow 2026-02-19 21:11:02 +00:00
Henry
b44a1296a0 feat: update remote patterns to be more permissive 2026-02-19 14:38:09 +00:00
Henry
3bf132286b fix: use correct context 2026-02-19 13:17:49 +00:00
Henry
c9c3365c0a fix: use correct path to dockerfile 2026-02-19 13:15:06 +00:00
Henry
7c2937a7b7 fix: add migrator container name to docker compose 2026-02-19 13:12:38 +00:00
Henry
f27a031ea8 feat(cloud): add migrator to docker-compose 2026-02-19 12:52:58 +00:00
Henry
05a0245988 feat: improve pricing tiers and add comparison table (#405) 2026-02-18 22:46:32 +00:00
Matt
b6b9423823 fix: increase max workspace url length from 24 to 64 characters (closes #379( (#398) 2026-02-17 13:40:58 +00:00
Owais Rizvi
1696aab43b fix: handle empty commenter name in mention notification emails (#400)
Use `||` instead of `??` so empty strings also fall back to email.
The nullish coalescing operator (`??`) only catches null/undefined,
so users with an empty name string would appear nameless in emails.
2026-02-17 13:39:24 +00:00
Nick Meinhold
33d2f23955 feat(db): add webhook schema, migration, and repository (#391)
* feat(db): add webhook schema, migration, and repository

Add the database foundation for workspace webhooks:

- Add workspace_webhooks table with migration (webhook_event enum,
  URL, secret, event subscriptions, active flag)
- Add webhook repository with CRUD operations
- Add webhooks schema definition with relations
- Extend card and list repos to return board/list names for
  webhook payload context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(db): remove unused webhook_event enum and fix migration timestamp

- Remove dead webhook_event pgEnum from schema (events column uses text)
- Remove CREATE TYPE statement from migration SQL
- Fix migration journal timestamp to be chronologically after the
  notifications migration (idx 25)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(db): return boardPublicId from card and list repo queries

Add board publicId to getWorkspaceAndCardIdByCardPublicId and
getWorkspaceAndListIdByListPublicId return values, needed for
correct boardId in webhook payloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(db): add workspaceId index and document secret exposure

- Add index on workspaceId for efficient webhook lookups per workspace
- Add JSDoc comment on getActiveByWorkspaceId explaining that it
  returns secrets for server-side HMAC signing only and must never
  be exposed via client-facing endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(db): extract parseEvents helper in webhook repo

DRY up 6 repeated JSON.parse-and-cast calls into a single helper
function, addressing reviewer feedback on PR #391.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:23:00 +00:00
Morfixx
961219835e refactor: docker image for less final size (#385) 2026-02-17 13:12:53 +00:00
Nick Meinhold
031a42b9eb fix: generate presigned avatar URLs for card members on board view (#388)
The board.byId query was generating presigned S3 URLs for workspace
member avatars but not for card member avatars. This caused card avatars
to silently fall back to initials since the frontend helper returns ""
for raw S3 keys.

Closes #387

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:10:23 +00:00
Matt
8a1189d011 feat: Activity log for uploading attachments (closes #354) (#366)
* feat: Activity log for uploading attachments (closes #354)

* chore: fix coding style

* revert: upload spinning icon patch

* refactor: add fallbacks, extended card_activity
2026-02-15 20:53:01 +00:00
Charity
53f6ada4d3 feat: add close button to card view header (#383) 2026-02-11 13:30:05 +00:00
Henry
6ea02b1db1 feat: send email notifications on user mentions (#372)
* feat: add notifications schema

* chore: gen migration

* feat: add mention email template

* feat: add sendMentionEmail func

* feat: add repo funcs

* feat: update card router to send emails on mention

* fix: update the editor suggestion to show all members

* feat: skip pending members in sendMentionEmails

* feat: update comments to use tiptap editor
2026-02-11 13:10:46 +00:00
Matt
737b1383d0 feat: add two types of roadmap templates (#369) 2026-02-11 13:03:18 +00:00
Matt
38d0e9495c Join Workspace Email improvements (#371)
* fix failing on percent-encoding invitation urls

* feat: add the workspace and inviter names

* fix: respect the NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY environment variable
2026-02-08 21:34:17 +00:00
Henry
19798ddbc9 fix: update pnpm-lock 2026-02-08 21:11:26 +00:00
Matt
08ba965921 fix: handle x-forwarded-host and proxy issues behind Cloudflare (#370) 2026-02-08 20:59:01 +00:00
kOaDT
008b5fd1a0 fix: prevent long card titles from overflowing column width (#367) 2026-02-08 20:54:44 +00:00
Henry
97cfd60b59 fix: use multipart upload for attachments to resolve chunk size error (#368) 2026-02-07 22:42:38 +00:00
Ivan Shelepugin
24e283e22c feat: smart typography (#360)
* build(web, deps): add `@tiptap/extension-typography`

* feat(editor): add typography extension

* chore(editor): disable some typographic characters

* chore(editor): disable superscript
2026-02-07 20:04:37 +00:00
Matt
07db7409cb fix: remove phantom spacing below social providers in auth form (closes #334) (#364) 2026-02-07 19:34:06 +00:00
Charity
346ddc16be feat: add functionality to copy card link (#358)
* Implemented link copying feature in CardDropdown and CardModal components.
* Updated Dropdown component to accept new props for card identification.
2026-02-06 23:34:23 +00:00
Charity
f9eee53ed0 fix: board and workspace URL links and copy behavior (#357)
- Use /boards/{id} for private board links so copied link works; keep pretty URL for public boards
- Show matching path in board header (pretty path for public, boards/id for private)
- Copy link button copies URL to clipboard instead of opening in new tab (opening a new tab seems redundant since we're already on it)
- Edit workspace URL button links to /settings/workspace instead of account settings
2026-02-06 23:31:08 +00:00
Henry
1ab4e24c96 docs: add railway deployment template to self hosting docs (#363) 2026-02-05 21:52:40 +00:00
Henry
34165b390f fix: handle missing board in board router 2026-02-04 22:56:17 +00:00
Henry
ef0d53db8f refactor: standardise S3 URL generation (#362)
* refactor: replace presigned URL uploads with backend upload endpoints

* feat: update avatar upload to use new endpoint

* refactor: use createS3Client in auth hooks

* feat: generate presigned URLs for avatars

* fix: show avatar image in user menu

* fix: hide tooltip if content is empty

* fix: support external avatar URLs in generateAvatarUrl

* fix: remove content type restriction on attachments
2026-02-04 22:40:14 +00:00
Henry
78b9de869f fix: prevent validation errors in workspace/card queries (#356)
* fix: prevent workspace.byId validation errors when publicId is empty

* fix: add keys to shortcut elements

* fix: prevent card.byId validation errors when cardPublicId is empty

* chore: remove invalid config option from next.config.js
2026-02-02 22:25:54 +00:00
Henry
81c03b51e2 fix: fallback to email when user name is missing in activity list (#355)
* fix: fallback to email when user name is missing in activity list

* chore: gen translations
2026-02-02 22:00:03 +00:00
Henry
0d3d9a358f fix: prevent ellipses from showing through member dropdown menu (#352) 2026-02-02 20:05:53 +00:00
144 changed files with 83844 additions and 27497 deletions

View File

@@ -1,18 +1,62 @@
# Environment
.env .env
.env.*
!.env.example
docker-compose.override.yml # Docker
docker-compose*.yml
Dockerfile
./**/*/Dockerfile
.dockerignore .dockerignore
# Dependencies (rebuilt in Docker)
node_modules node_modules
./**/*/node_modules **/node_modules
# Build outputs (rebuilt in Docker)
**/.next
**/dist
**/out
**/.turbo
**/.cache
# Git
.git
.gitignore
.gitattributes
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# CI/CD
.github
.husky
.changeset
# Documentation (not needed in build)
*.md
!packages/db/migrations/**
LICENSE
CONTRIBUTING.md
AGENTS.md
CHANGELOG.md
# Test files
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
**/__tests__
**/coverage
# Logs
pnpm-debug.log pnpm-debug.log
./**/*/pnpm-debug.log **/pnpm-debug.log
README.md # Cloud compose (separate deployment)
.next cloud/
# .git

View File

@@ -6,23 +6,31 @@ name: Docker
# documentation. # documentation.
on: on:
schedule:
- cron: "21 21 * * *"
push: push:
branches: ["main"] # Only trigger on tags (releases), not main branch pushes
# Publish semver tags as releases. # Main branch builds are handled by workflow_run after Translate completes
tags: ["v*.*.*"] tags: ["v*.*.*"]
pull_request: pull_request:
branches: ["main"] branches: ["main"]
workflow_run:
workflows: ["Translate"]
types: [completed]
branches: [main]
# Docker builds after Translate completes (success or failure)
# This ensures Docker always has the latest translations
env: env:
# Use docker.io for Docker Hub if empty # Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io REGISTRY: ghcr.io
# github.repository as <account>/<repo> # github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
jobs: jobs:
build: build:
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -74,7 +82,22 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
type=raw,value=edge,enable={{is_default_branch}}
- name: Extract Docker metadata (migrate)
id: meta-migrate
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.MIGRATE_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=${{ github.ref_type == 'tag' }}
type=raw,value=edge,enable={{is_default_branch}}
# Extract version from git tag or ref # Extract version from git tag or ref
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234 # Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
@@ -129,6 +152,22 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: Build and push migrate Docker image
id: build-and-push-migrate
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
context: .
file: apps/web/Dockerfile
target: migrate
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta-migrate.outputs.tags }}
labels: ${{ steps.meta-migrate.outputs.labels }}
build-args: |
APP_VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs. # Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker # 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 # repository is public to avoid leaking data. If you would like to publish
@@ -143,3 +182,10 @@ jobs:
# This step uses the identity token to provision an ephemeral certificate # This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance. # against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
- name: Sign the published migrate Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
TAGS: ${{ steps.meta-migrate.outputs.tags }}
DIGEST: ${{ steps.build-and-push-migrate.outputs.digest }}
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

56
.github/workflows/translate.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Translate
on:
push:
branches: [main]
permissions:
contents: write
jobs:
translate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Install dependencies
run: pnpm install --filter @kan/web...
- name: Extract translations
working-directory: apps/web
run: pnpm lingui:extract
- name: Lingo.dev
uses: lingodotdev/lingo.dev@main
with:
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
commit-message: "chore: update translations"
working-directory: apps/web
commit-author-name: "${{ github.actor }}"
commit-author-email: "${{ github.actor }}@users.noreply.github.com"
parallel: true
- name: Compile translations
working-directory: apps/web
run: pnpm lingui:compile
- name: Commit compiled translations
run: |
git config --local user.email "${{ github.actor }}@users.noreply.github.com"
git config --local user.name "${{ github.actor }}"
if ls apps/web/src/locales/*/messages.ts 1> /dev/null 2>&1; then
git add apps/web/src/locales/*/messages.ts
git diff --staged --quiet || (git commit -m "chore: compile translations" && git push)
fi

View File

@@ -47,39 +47,71 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Self Hosting 🐳 ## Self Hosting 🐳
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database. ### One-click Deployments
1. Create a new file called `docker-compose.yml` and paste the following configuration: The easiest way to deploy Kan is through Railway. We've partnered with Railway to maintain an official template that supports the development of the project.
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
</a>
### Docker Compose
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database and automatically run migrations.
1. Create a `.env` file with your environment variables (see [Environment Variables](#environment-variables-) section below)
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
```yaml ```yaml
services: services:
migrate:
image: ghcr.io/kanbn/kan-migrate:latest
container_name: kan-migrate
networks:
- kan-network
environment:
- POSTGRES_URL=${POSTGRES_URL}
depends_on:
postgres:
condition: service_healthy
restart: "no"
web: web:
image: ghcr.io/kanbn/kan:latest image: ghcr.io/kanbn/kan:latest
container_name: kan-web container_name: kan-web
ports: ports:
- "3000:3000" - "${WEB_PORT:-3000}:3000"
networks: networks:
- kan-network - kan-network
env_file:
- .env
environment: environment:
NEXT_PUBLIC_BASE_URL: http://localhost:3000 - NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
BETTER_AUTH_SECRET: your_auth_secret - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db - POSTGRES_URL=${POSTGRES_URL}
NEXT_PUBLIC_ALLOW_CREDENTIALS: true - NEXT_PUBLIC_ALLOW_CREDENTIALS=true
depends_on: depends_on:
- postgres migrate:
condition: service_completed_successfully
restart: unless-stopped restart: unless-stopped
postgres: postgres:
image: postgres:15 image: postgres:15
container_name: kan-db container_name: kan-db
environment: environment:
POSTGRES_DB: kan_db - POSTGRES_DB=kan_db
POSTGRES_USER: kan - POSTGRES_USER=kan
POSTGRES_PASSWORD: your_postgres_password - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ports: ports:
- 5432:5432 - 5432:5432
volumes: volumes:
- kan_postgres_data:/var/lib/postgresql/data - kan_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped restart: unless-stopped
networks: networks:
- kan-network - kan-network
@@ -91,23 +123,23 @@ volumes:
kan_postgres_data: kan_postgres_data:
``` ```
2. Start the containers in detached mode: 3. Start the containers in detached mode:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
3. Access Kan at http://localhost:3000 The `migrate` service will automatically run database migrations before the web service starts. The application will be available at http://localhost:3000 (or the port specified in `WEB_PORT`).
The application will be running in the background. You can manage the containers using these commands: **Managing containers:**
- To stop the containers: `docker compose down` - To stop the containers: `docker compose down`
- To view logs: `docker compose logs -f` - To view logs: `docker compose logs -f`
- To view logs for a specific service: `docker compose logs -f web` or `docker compose logs -f migrate`
- To restart the containers: `docker compose restart` - To restart the containers: `docker compose restart`
- To rebuild after code changes: `docker compose up -d --build`
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository. For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
## Local Development 🧑‍💻 ## Local Development 🧑‍💻

View File

@@ -1,25 +1,23 @@
ARG NODE_VERSION=20 # syntax=docker/dockerfile:1.7
ARG APP_DIRNAME=web
ARG PROJECT=@kan/web
# 1. Alpine image ARG NODE_VERSION=20
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
# ============================================
# Stage 1: Alpine base with pnpm and turbo
# ============================================
FROM node:${NODE_VERSION}-alpine AS alpine FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update && \ RUN apk update && \
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \ apk add --no-cache libc6-compat && \
rm -rf /var/cache/apk/* /tmp/* || true rm -rf /var/cache/apk/* /tmp/* || true && \
corepack enable && \
npm install turbo@2.3.1 --global && \
pnpm config set store-dir ~/.pnpm-store
# Setup pnpm and turbo on the alpine base # ============================================
FROM alpine AS base # Stage 2: Prune the monorepo
RUN corepack enable # ============================================
# Replace <your-major-version> with the major version installed in your repository. For example: FROM alpine AS pruner
# RUN npm install turbo@2.1.3 --global
RUN npm install turbo@2.3.1 --global
RUN pnpm config set store-dir ~/.pnpm-store
# 2. Prune projects
FROM base AS pruner
ARG PROJECT
RUN apk add --no-cache git RUN apk add --no-cache git
@@ -35,53 +33,89 @@ RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null ||
echo "unknown") && \ echo "unknown") && \
echo "$AUTO_VERSION" > /app/AUTO_VERSION echo "$AUTO_VERSION" > /app/AUTO_VERSION
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
# 3. Build the project
FROM base AS builder
ARG PROJECT
ARG APP_VERSION
# ============================================
# Stage 3: Install dependencies
# ============================================
FROM alpine AS deps
WORKDIR /app WORKDIR /app
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
ENV CI=true ENV CI=true
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ . # ============================================
# Stage 4: Build the web application
# Use provided APP_VERSION or auto-generated from pruner stage # ============================================
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \ FROM alpine AS builder
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT} ARG APP_VERSION
# # Copy static files to standalone directory
# RUN mkdir -p apps/web/.next/standalone/.next && \
# mv apps/web/public apps/web/.next/standalone/ && \
# mv apps/web/.next/static apps/web/.next/standalone/.next/
# 4. Final image - runner stage to run the application
FROM base AS runner
ARG APP_DIRNAME
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
WORKDIR /app WORKDIR /app
COPY --from=deps /app/ ./
COPY --from=pruner /app/out/full/ .
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
# Force standalone output for production Docker image
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true
ENV CI=true
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
# ============================================
# Stage 5: Migration image (run-once container)
# ============================================
FROM node:${NODE_VERSION}-alpine AS migrate
WORKDIR /db
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
COPY packages/db/migrations/ ./migrations/
RUN npm init -y && \
npm install drizzle-kit drizzle-orm pg --save-exact && \
# Strip unnecessary files from node_modules
find node_modules -type f \( \
-name '*.d.ts' -o \
-name '*.d.mts' -o \
-name '*.d.cts' -o \
-name '*.map' -o \
-name '*.md' -o \
-name '*.txt' -o \
-name 'LICENSE*' -o \
-name 'CHANGELOG*' -o \
-name 'README*' -o \
-name '.eslint*' -o \
-name '.prettier*' -o \
-name 'tsconfig*.json' \
\) -delete && \
find node_modules -type d -empty -delete && \
rm -rf /root/.npm /tmp/*
CMD ["npx", "drizzle-kit", "migrate"]
# ============================================
# Stage 6: Production web image (distroless)
# ============================================
FROM ${DISTROLESS_NODE_IMAGE} AS web
WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --chown=nextjs:nodejs --from=builder /app/ ./ # Copy the standalone Next.js server
WORKDIR /app/apps/${APP_DIRNAME} COPY --from=builder /app/apps/web/.next/standalone/ ./
# Copy static assets and public files
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
ARG PORT=3000 # Copy bootstrap script for runtime env var injection
ENV PORT=${PORT} COPY apps/web/bootstrap.cjs ./bootstrap.cjs
EXPOSE ${PORT}
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"] EXPOSE 3000
CMD ["bootstrap.cjs"]

44
apps/web/bootstrap.cjs Normal file
View File

@@ -0,0 +1,44 @@
/**
* Bootstrap script for the distroless production image.
*
* Distroless images have no shell, so this Node.js script handles two tasks
* that would normally be done in an entrypoint.sh:
*
* 1. Regenerate `public/__ENV.js` with the current runtime NEXT_PUBLIC_*
* environment variables. The file was originally created at build time by
* next-runtime-env's `configureRuntimeEnv()`, but in a Docker deployment the
* env vars are provided at *run* time via docker-compose / docker run.
*
* 2. Start the Next.js standalone server.
*/
const { writeFileSync, existsSync, mkdirSync } = require("fs");
const path = require("path");
// ---------------------------------------------------------------------------
// 1. Inject runtime NEXT_PUBLIC_* env vars into __ENV.js
// ---------------------------------------------------------------------------
const publicDir = path.join(__dirname, "apps", "web", "public");
if (!existsSync(publicDir)) {
mkdirSync(publicDir, { recursive: true });
}
const envVars = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith("NEXT_PUBLIC_")) {
envVars[key] = value;
}
}
writeFileSync(
path.join(publicDir, "__ENV.js"),
`self.__ENV = ${JSON.stringify(envVars)};`,
);
// ---------------------------------------------------------------------------
// 2. Start the Next.js standalone server
// ---------------------------------------------------------------------------
require("./apps/web/server.js");

View File

@@ -1,12 +1,12 @@
{ {
"version": 0, "version": "1.10",
"locale": { "locale": {
"source": "en", "source": "en",
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"] "targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
}, },
"buckets": { "buckets": {
"po": { "json": {
"include": ["src/locales/[locale]/messages.po"] "include": ["src/locales/[locale]/messages.json"]
} }
}, },
"$schema": "https://lingo.dev/schema/i18n.json" "$schema": "https://lingo.dev/schema/i18n.json"

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
import type { LinguiConfig } from "@lingui/conf"; import { defineConfig } from "@lingui/cli";
import { formatter } from "@lingui/format-json";
const config: LinguiConfig = {
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"], export default defineConfig({
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
sourceLocale: "en", sourceLocale: "en",
catalogs: [ catalogs: [
{ {
@@ -10,6 +12,5 @@ const config: LinguiConfig = {
exclude: ["**/node_modules/**"], exclude: ["**/node_modules/**"],
}, },
], ],
}; format: formatter({ style: "lingui" }),
});
export default config;

View File

@@ -16,6 +16,18 @@ const config = {
: undefined, : undefined,
reactStrictMode: true, reactStrictMode: true,
/** Exclude build tools and dev-only packages from the standalone output */
outputFileTracingExcludes: {
"**/*": [
"@esbuild/**",
"esbuild/**",
"typescript/**",
"webpack/**",
"uglify-js/**",
"terser/**",
],
},
/** Enables hot reloading for local packages without a build step */ /** Enables hot reloading for local packages without a build step */
transpilePackages: [ transpilePackages: [
"@kan/api", "@kan/api",
@@ -35,50 +47,13 @@ const config = {
remotePatterns: (() => { remotePatterns: (() => {
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */ /** @type {Array<{protocol: "http" | "https", hostname: string}>} */
const patterns = [ const patterns = [
{ { protocol: "https", hostname: "**" },
protocol: "https",
hostname:
env("S3_FORCE_PATH_STYLE") === "true"
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
},
{ {
protocol: "http", protocol: "http",
hostname: "localhost", hostname: "localhost",
}, },
{
protocol: "https",
hostname: "*.googleusercontent.com",
},
{
protocol: 'https',
hostname: 'cdn.discordapp.com',
pathname: '/avatars/**',
},
]; ];
// Extract root domain from S3_ENDPOINT and add wildcard pattern
const s3Endpoint = env("S3_ENDPOINT");
if (s3Endpoint) {
try {
const url = new URL(s3Endpoint);
const hostname = url.hostname;
const protocol = url.protocol.replace(":", "");
// Extract root domain (last 2 parts: e.g. cloudflarestorage.com)
const parts = hostname.split(".");
if (parts.length >= 2) {
const rootDomain = parts.slice(-2).join(".");
patterns.push({
protocol: protocol === "http" ? "http" : "https",
hostname: `*.${rootDomain}`,
});
}
} catch {
// If S3_ENDPOINT is not a valid URL, ignore it
}
}
return patterns; return patterns;
})(), })(),
}, },
@@ -95,12 +70,6 @@ const config = {
swcPlugins: [["@lingui/swc-plugin", {}]], swcPlugins: [["@lingui/swc-plugin", {}]],
}, },
api: {
bodyParser: {
sizeLimit: env("NEXT_API_BODY_SIZE_LIMIT") || '1mb',
},
},
async rewrites() { async rewrites() {
return [ return [
{ {

View File

@@ -20,6 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.802.0", "@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/lib-storage": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0", "@aws-sdk/s3-request-presigner": "^3.812.0",
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
@@ -38,6 +39,7 @@
"@tiptap/extension-link": "^2.22.2", "@tiptap/extension-link": "^2.22.2",
"@tiptap/extension-mention": "^3.0.9", "@tiptap/extension-mention": "^3.0.9",
"@tiptap/extension-placeholder": "^2.14.0", "@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/extension-typography": "^3.18.0",
"@tiptap/pm": "^2.14.0", "@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0", "@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0", "@tiptap/starter-kit": "^2.14.0",
@@ -77,6 +79,7 @@
"@kan/tailwind-config": "workspace:*", "@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*", "@kan/tsconfig": "workspace:*",
"@lingui/cli": "^5.3.2", "@lingui/cli": "^5.3.2",
"@lingui/format-json": "^5.9.1",
"@lingui/loader": "^5.3.2", "@lingui/loader": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2", "@lingui/swc-plugin": "^5.5.2",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",

View File

@@ -361,8 +361,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
})} })}
</div> </div>
)} )}
<form onSubmit={handleSubmit(onSubmit)}> {!(isCredentialsEnabled || isMagicLinkAvailable) &&
{!isCredentialsEnabled && socialProviders?.length === 0 && ( socialProviders?.length === 0 && (
<div className="flex w-full items-center gap-4"> <div className="flex w-full items-center gap-4">
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" /> <div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
<span className="text-center text-sm text-light-900 dark:text-dark-900"> <span className="text-center text-sm text-light-900 dark:text-dark-900">
@@ -371,65 +371,62 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" /> <div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
</div> </div>
)} )}
{!isCredentialsEnabled && socialProviders?.length !== 0 && ( {(isCredentialsEnabled || isMagicLinkAvailable) && (
<div className="mb-[1.5rem] flex w-full items-center gap-4"> <form onSubmit={handleSubmit(onSubmit)}>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" /> {socialProviders?.length !== 0 && (
<span className="text-sm text-light-900 dark:text-dark-900"> <div className="mb-[1.5rem] flex w-full items-center gap-4">
{t`or`} <div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</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" /> {t`or`}
</div> </span>
)} <div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<div className="space-y-2">
{isSignUp && isCredentialsEnabled && (
<div>
<Input
{...register("name", { required: true })}
placeholder={t`Enter your name`}
/>
{errors.name && (
<p className="mt-2 text-xs text-red-400">
{t`Please enter a valid name`}
</p>
)}
</div> </div>
)} )}
{(isCredentialsEnabled || isMagicLinkAvailable) && ( <div className="space-y-2">
<> {isSignUp && isCredentialsEnabled && (
<div> <div>
<Input <Input
{...register("email", { required: true })} {...register("name", { required: true })}
placeholder={t`Enter your email address`} placeholder={t`Enter your name`}
/> />
{errors.email && ( {errors.name && (
<p className="mt-2 text-xs text-red-400"> <p className="mt-2 text-xs text-red-400">
{t`Please enter a valid email address`} {t`Please enter a valid name`}
</p> </p>
)} )}
</div> </div>
)}
{isCredentialsEnabled && ( <div>
<div> <Input
<Input {...register("email", { required: true })}
type="password" placeholder={t`Enter your email address`}
{...register("password", { required: true })} />
placeholder={t`Enter your password`} {errors.email && (
/> <p className="mt-2 text-xs text-red-400">
{errors.password && ( {t`Please enter a valid email address`}
<p className="mt-2 text-xs text-red-400"> </p>
{errors.password.message ??
t`Please enter a valid password`}
</p>
)}
</div>
)} )}
</> </div>
)}
{loginError && ( {isCredentialsEnabled && (
<p className="mt-2 text-xs text-red-400">{loginError}</p> <div>
)} <Input
</div> type="password"
{(isCredentialsEnabled || isMagicLinkAvailable) && ( {...register("password", { required: true })}
placeholder={t`Enter your password`}
/>
{errors.password && (
<p className="mt-2 text-xs text-red-400">
{errors.password.message ??
t`Please enter a valid password`}
</p>
)}
</div>
)}
{loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div>
<div className="mt-[1.5rem] flex items-center gap-4"> <div className="mt-[1.5rem] flex items-center gap-4">
<Button <Button
isLoading={isLoginWithEmailPending} isLoading={isLoginWithEmailPending}
@@ -441,8 +438,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
{isMagicLinkMode ? t`magic link` : t`email`} {isMagicLinkMode ? t`magic link` : t`email`}
</Button> </Button>
</div> </div>
)} </form>
</form> )}
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div> </div>
); );
} }

View File

@@ -25,7 +25,7 @@ const Avatar = ({
icon?: React.ReactNode; icon?: React.ReactNode;
isLoading?: boolean; isLoading?: boolean;
}) => { }) => {
const initials = name const initials = name?.trim()
? getInitialsFromName(name) ? getInitialsFromName(name)
: inferInitialsFromEmail(email); : inferInitialsFromEmail(email);

View File

@@ -12,6 +12,7 @@ import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside"; import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace"; import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
import { api } from "~/utils/api";
import SideNavigation from "./SideNavigation"; import SideNavigation from "./SideNavigation";
interface DashboardProps { interface DashboardProps {
@@ -44,6 +45,12 @@ export default function Dashboard({
const { availableWorkspaces, hasLoaded } = useWorkspace(); const { availableWorkspaces, hasLoaded } = useWorkspace();
const { data: session, isPending: sessionLoading } = authClient.useSession(); const { data: session, isPending: sessionLoading } = authClient.useSession();
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
undefined,
{
enabled: !!session?.user,
},
);
const [isSideNavOpen, setIsSideNavOpen] = useState(false); const [isSideNavOpen, setIsSideNavOpen] = useState(false);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
@@ -155,8 +162,12 @@ export default function Dashboard({
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `} className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
> >
<SideNavigation <SideNavigation
user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }} user={{
isLoading={sessionLoading} displayName: user?.name ?? session?.user.name,
email: user?.email ?? session?.user.email ?? "",
image: user?.image ?? undefined,
}}
isLoading={sessionLoading || userLoading}
onCloseSideNav={closeSideNav} onCloseSideNav={closeSideNav}
/> />
</div> </div>

View File

@@ -17,6 +17,7 @@ import {
ReactRenderer, ReactRenderer,
useEditor, useEditor,
} from "@tiptap/react"; } from "@tiptap/react";
import Typography from "@tiptap/extension-typography";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion"; import Suggestion from "@tiptap/suggestion";
import { import {
@@ -386,46 +387,54 @@ export interface SlashNodeAttrs {
label?: string | null; label?: string | null;
} }
const CommandItems: SlashCommandItem[] = [ const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => {
{ const headingCommands: SlashCommandItem[] = disableHeadings
title: "Heading 1", ? []
icon: <HiH1 />, : [
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 1 }).run(), title: "Heading 1",
}, icon: <HiH1 />,
{ command: ({ editor }) =>
title: "Heading 2", editor.chain().focus().setHeading({ level: 1 }).run(),
icon: <HiH2 />, },
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 2 }).run(), title: "Heading 2",
}, icon: <HiH2 />,
{ command: ({ editor }) =>
title: "Heading 3", editor.chain().focus().setHeading({ level: 2 }).run(),
icon: <HiH3 />, },
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 3 }).run(), title: "Heading 3",
}, icon: <HiH3 />,
{ command: ({ editor }) =>
title: "Bullet List", editor.chain().focus().setHeading({ level: 3 }).run(),
icon: <HiOutlineListBullet />, },
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(), ];
},
{ return [
title: "Ordered List", ...headingCommands,
icon: <HiOutlineNumberedList />, {
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(), title: "Bullet List",
}, icon: <HiOutlineListBullet />,
{ command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
title: "Blockquote", },
icon: <HiOutlineChatBubbleLeftEllipsis />, {
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(), title: "Ordered List",
}, icon: <HiOutlineNumberedList />,
{ command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
title: "Code Block", },
icon: <HiOutlineCodeBracketSquare />, {
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(), title: "Blockquote",
}, icon: <HiOutlineChatBubbleLeftEllipsis />,
]; command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
},
{
title: "Code Block",
icon: <HiOutlineCodeBracketSquare />,
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
},
];
};
export default function Editor({ export default function Editor({
content, content,
@@ -434,6 +443,8 @@ export default function Editor({
readOnly = false, readOnly = false,
workspaceMembers, workspaceMembers,
enableYouTubeEmbed = true, enableYouTubeEmbed = true,
placeholder,
disableHeadings = false,
}: { }: {
content: string | null; content: string | null;
onChange?: (value: string) => void; onChange?: (value: string) => void;
@@ -441,18 +452,16 @@ export default function Editor({
readOnly?: boolean; readOnly?: boolean;
workspaceMembers: WorkspaceMember[]; workspaceMembers: WorkspaceMember[];
enableYouTubeEmbed?: boolean; enableYouTubeEmbed?: boolean;
placeholder?: string;
disableHeadings?: boolean;
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const editor = useEditor( const editor = useEditor(
{ {
extensions: [ extensions: [
StarterKit, StarterKit.configure({
Markdown, heading: disableHeadings ? false : undefined,
Placeholder.configure({
placeholder: readOnly
? ""
: t`Add description... (type '/' to open commands or '@' to mention)`,
}), }),
Link.configure({ Link.configure({
openOnClick: true, openOnClick: true,
@@ -463,12 +472,20 @@ export default function Editor({
}, },
validate: (href) => /^https?:\/\//.test(href), validate: (href) => /^https?:\/\//.test(href),
autolink: true, autolink: true,
linkOnPaste: true,
}),
Markdown,
Placeholder.configure({
placeholder: readOnly
? ""
: placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`,
}), }),
SlashCommands.configure({ SlashCommands.configure({
commandItems: CommandItems, commandItems: getCommandItems(disableHeadings),
suggestion: { suggestion: {
items: ({ query }: { query: string }) => items: ({ query }: { query: string }) =>
filterSlashCommandItems(CommandItems, query), filterSlashCommandItems(getCommandItems(disableHeadings), query),
startOfLine: true, startOfLine: true,
char: "/", char: "/",
}, },
@@ -480,20 +497,28 @@ export default function Editor({
suggestion: { suggestion: {
char: "@", char: "@",
items: ({ query }: { query: string }) => { items: ({ query }: { query: string }) => {
const all: MentionItem[] = workspaceMembers.map( const withEmail = workspaceMembers.filter((member) => member.email);
(member: WorkspaceMember) => ({
id: member.publicId, const mapped = withEmail.map((member: WorkspaceMember) => ({
label: member?.user?.name ?? member.email, id: member.publicId,
image: member?.user?.image ?? null, label: member?.user?.name?.trim() || member.email || "",
}), image: member?.user?.image ?? null,
}));
const all: MentionItem[] = mapped.filter(
(item) => item.label && item.label.length > 0,
); );
const q = query.toLowerCase();
return all.filter( const q = query.toLowerCase().trim();
(u) =>
u.label && if (q === "") {
typeof u.label === "string" && return all;
u.label.toLowerCase().includes(q), }
const filtered = all.filter((u) =>
u.label.toLowerCase().includes(q),
); );
return filtered;
}, },
command: ({ editor, range, props }) => { command: ({ editor, range, props }) => {
const id = props.id ?? ""; const id = props.id ?? "";
@@ -514,6 +539,17 @@ export default function Editor({
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`; return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
}, },
}), }),
Typography.configure({
openDoubleQuote: false,
closeDoubleQuote: false,
openSingleQuote: false,
closeSingleQuote: false,
oneHalf: false,
oneQuarter: false,
threeQuarters: false,
superscriptTwo: false,
superscriptThree: false,
}),
...(enableYouTubeEmbed ? [YouTubeNode] : []), ...(enableYouTubeEmbed ? [YouTubeNode] : []),
], ],
content, content,

View File

@@ -33,7 +33,7 @@ const schema = z.object({
.min(3, { .min(3, {
message: t`URL must be at least 3 characters long`, message: t`URL must be at least 3 characters long`,
}) })
.max(24, { message: t`URL cannot exceed 24 characters` }) .max(64, { message: t`URL cannot exceed 64 characters` })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, { .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`, message: t`URL can only contain letters, numbers, and hyphens`,
}) })

View File

@@ -55,9 +55,10 @@ export default function SideNavigation({
const [isInitialised, setIsInitialised] = useState(false); const [isInitialised, setIsInitialised] = useState(false);
const { openModal } = useModal(); const { openModal } = useModal();
const { data: workspaceData } = api.workspace.byId.useQuery({ const { data: workspaceData } = api.workspace.byId.useQuery(
workspacePublicId: workspace.publicId, { workspacePublicId: workspace.publicId },
}); { enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
);
const subscriptions = workspaceData?.subscriptions as const subscriptions = workspaceData?.subscriptions as
| Subscription[] | Subscription[]

View File

@@ -7,7 +7,7 @@ import tippy from "tippy.js";
interface TooltipProps { interface TooltipProps {
children: ReactNode; children: ReactNode;
content: ReactNode; content?: ReactNode;
placement?: Placement; placement?: Placement;
delay?: number | [number, number]; delay?: number | [number, number];
} }
@@ -24,6 +24,8 @@ export function Tooltip({
useEffect(() => { useEffect(() => {
if (!triggerRef.current) return; if (!triggerRef.current) return;
if (!content) return;
const container = document.createElement("div"); const container = document.createElement("div");
const root = createRoot(container); const root = createRoot(container);
rootRef.current = root; rootRef.current = root;

View File

@@ -19,6 +19,7 @@ interface UsePermissionsResult {
canCreateBoard: boolean; canCreateBoard: boolean;
canEditBoard: boolean; canEditBoard: boolean;
canDeleteBoard: boolean; canDeleteBoard: boolean;
canArchiveBoard: boolean;
canViewComment: boolean; canViewComment: boolean;
canCreateComment: boolean; canCreateComment: boolean;
canEditComment: boolean; canEditComment: boolean;
@@ -51,6 +52,7 @@ export function usePermissions(): UsePermissionsResult {
canCreateBoard: false, canCreateBoard: false,
canEditBoard: false, canEditBoard: false,
canDeleteBoard: false, canDeleteBoard: false,
canArchiveBoard: false,
canViewComment: false, canViewComment: false,
canCreateComment: false, canCreateComment: false,
canEditComment: false, canEditComment: false,
@@ -95,6 +97,7 @@ export function usePermissions(): UsePermissionsResult {
canCreateBoard: hasPermission("board:create"), canCreateBoard: hasPermission("board:create"),
canEditBoard: hasPermission("board:edit"), canEditBoard: hasPermission("board:edit"),
canDeleteBoard: hasPermission("board:delete"), canDeleteBoard: hasPermission("board:delete"),
canArchiveBoard: hasPermission("board:edit"),
canViewComment: hasPermission("comment:view"), canViewComment: hasPermission("comment:view"),
canCreateComment: hasPermission("comment:create"), canCreateComment: hasPermission("comment:create"),
canEditComment: hasPermission("comment:edit"), canEditComment: hasPermission("comment:edit"),

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@ export const locales = [
"nl", "nl",
"ru", "ru",
"pl", "pl",
"pt-BR" "ptbr"
] as const; ] as const;
export type Locale = (typeof locales)[number]; export type Locale = (typeof locales)[number];
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
nl: "Nederlands", nl: "Nederlands",
ru: "Русский", ru: "Русский",
pl: "Polski", pl: "Polski",
"pt-BR": "Português", ptbr: "Português",
}; };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -13,6 +13,23 @@ const authHandler = toNodeHandler(auth.handler);
export default withRateLimit( export default withRateLimit(
{ points: 100, duration: 60 }, { points: 100, duration: 60 },
async (req, res) => { async (req, res) => {
/**
* Better-auth behind proxies (Nginx/Cloudflare) can sometimes fail to parse the protocol
* if headers are incorrectly set or if there are multiple values in X-Forwarded-Proto.
* We sanitize these headers here to ensure better-auth gets a clean protocol and host.
*/
const forwardedProto = req.headers["x-forwarded-proto"];
if (forwardedProto) {
const p = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
req.headers["x-forwarded-proto"] = p?.split(",")[0]?.trim();
}
const forwardedHost = req.headers["x-forwarded-host"];
if (forwardedHost) {
const h = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
req.headers["host"] = h?.split(",")[0]?.trim();
}
return await authHandler(req, res); return await authHandler(req, res);
}, },
); );

View File

@@ -2,48 +2,74 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { withRateLimit } from "@kan/api/utils/rateLimit"; import { withRateLimit } from "@kan/api/utils/rateLimit";
import { env } from "~/env";
export default withRateLimit( export default withRateLimit(
{ points: 100, duration: 60 }, { points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => { async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") { if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" }); return res.status(405).json({ message: "Method not allowed" });
}
const { url, filename } = req.query;
if (!url || typeof url !== "string") {
return res.status(400).json({
message: "url parameter is required",
});
}
try {
const downloadFilename = typeof filename === "string"
? encodeURIComponent(filename)
: "attachment";
const upstream = await fetch(url);
if (!upstream.ok) {
return res.status(upstream.status).json({
message: "Failed to fetch attachment",
});
} }
const contentType = const { url, filename } = req.query;
upstream.headers.get("Content-Type") ?? "application/octet-stream";
res.setHeader("Content-Type", contentType); if (!url || typeof url !== "string") {
res.setHeader( return res.status(400).json({ message: "url parameter is required" });
"Content-Disposition", }
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
);
const buffer = await upstream.arrayBuffer(); const s3Endpoint = env.S3_ENDPOINT;
return res.send(Buffer.from(buffer));
} catch (error) { if (s3Endpoint) {
console.error("Error downloading attachment:", error); let parsed: URL;
return res.status(500).json({ message: "Failed to download attachment" }); try {
} parsed = new URL(url);
} catch {
return res.status(400).json({ message: "Invalid URL" });
}
const hostname = parsed.hostname.toLowerCase();
let allowedHost: string;
try {
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
} catch {
return res.status(500).json({ message: "Storage endpoint misconfigured" });
}
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
return res.status(403).json({ message: "URL not allowed" });
}
}
try {
const downloadFilename =
typeof filename === "string"
? encodeURIComponent(filename)
: "attachment";
const upstream = await fetch(url);
if (!upstream.ok) {
return res
.status(upstream.status)
.json({ message: "Failed to fetch attachment" });
}
const contentType =
upstream.headers.get("Content-Type") ?? "application/octet-stream";
res.setHeader("Content-Type", contentType);
res.setHeader(
"Content-Disposition",
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
);
const buffer = await upstream.arrayBuffer();
return res.send(Buffer.from(buffer));
} catch (error) {
console.error("Error downloading attachment:", error);
return res
.status(500)
.json({ message: "Failed to download attachment" });
}
}, },
); );

View File

@@ -11,7 +11,7 @@ import { withRateLimit } from "@kan/api/utils/rateLimit";
const workspaceSlugSchema = z const workspaceSlugSchema = z
.string() .string()
.min(3) .min(3)
.max(24) .max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/); .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
interface CheckoutSessionRequest { interface CheckoutSessionRequest {

View File

@@ -0,0 +1,136 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { Upload } from "@aws-sdk/lib-storage";
import { createNextApiContext } from "@kan/api/trpc";
import { assertPermission } from "@kan/api/utils/permissions";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
import { createS3Client, generateUID } from "@kan/shared/utils";
import { env } from "~/env";
// FIXME: Respect the environment variable: NEXT_API_BODY_SIZE_LIMIT
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
export const config = {
api: {
bodyParser: false,
},
};
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user, db } = await createNextApiContext(req);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
if (!bucket) {
return res.status(500).json({ error: "Attachments bucket not configured" });
}
const cardPublicId = req.query.cardPublicId;
if (typeof cardPublicId !== "string" || cardPublicId.length < 12) {
return res.status(400).json({ error: "Invalid cardPublicId" });
}
const contentType = req.headers["content-type"];
const contentLengthHeader = req.headers["content-length"];
const contentLength = contentLengthHeader
? Number.parseInt(contentLengthHeader, 10)
: NaN;
if (typeof contentType !== "string") {
return res.status(400).json({ error: "Missing content type" });
}
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" });
}
if (contentLength > MAX_SIZE_BYTES) {
return res.status(400).json({ error: "File too large" });
}
const originalFilenameHeader =
(req.headers["x-original-filename"] as string | undefined) ?? "file";
const sanitizedFilename = originalFilenameHeader
.replace(/[^a-zA-Z0-9._-]/g, "_")
.substring(0, 200);
// Get card and check permissions
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
db,
cardPublicId,
);
if (!card) {
return res.status(404).json({ error: "Card not found" });
}
// Check if user has permission to edit the card
try {
await assertPermission(db, user.id, card.workspaceId, "card:edit");
} catch {
return res.status(403).json({ error: "Permission denied" });
}
const s3Key = `${card.workspaceId}/${cardPublicId}/${generateUID()}-${sanitizedFilename}`;
const client = createS3Client();
const upload = new Upload({
client,
params: {
Bucket: bucket,
Key: s3Key,
Body: req,
ContentType: contentType,
ContentLength: contentLength,
},
leavePartsOnError: false,
});
await upload.done();
// Create attachment record and log activity
const attachment = await cardAttachmentRepo.create(db, {
cardId: card.id,
filename: sanitizedFilename,
originalFilename: originalFilenameHeader,
contentType,
size: contentLength,
s3Key,
createdBy: user.id,
});
if (!attachment) {
return res.status(500).json({ error: "Failed to create attachment" });
}
await cardActivityRepo.create(db, {
type: "card.updated.attachment.added",
cardId: card.id,
attachmentId: attachment.id,
toTitle: originalFilenameHeader,
createdBy: user.id,
});
return res.status(200).json({ attachment });
} catch (error) {
console.error("Attachment upload failed", error);
return res.status(500).json({ error: "Internal server error" });
}
},
);

View File

@@ -0,0 +1,101 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { createNextApiContext } from "@kan/api/trpc";
import * as userRepo from "@kan/db/repository/user.repo";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createS3Client } from "@kan/shared/utils";
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
export const config = {
api: {
bodyParser: false,
},
};
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user, db } = await createNextApiContext(req);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME;
if (!bucket) {
return res.status(500).json({ error: "Avatar bucket not configured" });
}
const contentType = req.headers["content-type"];
const contentLengthHeader = req.headers["content-length"];
const contentLength = contentLengthHeader
? Number.parseInt(contentLengthHeader, 10)
: NaN;
if (typeof contentType !== "string") {
return res.status(400).json({ error: "Missing content type" });
}
if (!allowedContentTypes.includes(contentType)) {
return res.status(400).json({ error: "Invalid content type" });
}
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" });
}
if (contentLength > MAX_SIZE_BYTES) {
return res.status(400).json({ error: "File too large" });
}
const originalFilenameHeader =
(req.headers["x-original-filename"] as string | undefined) ?? "file";
const sanitizedFilename = originalFilenameHeader
.replace(/[^a-zA-Z0-9._-]/g, "_")
.substring(0, 200);
const s3Key = `${user.id}/${sanitizedFilename}`;
const client = createS3Client();
// Upload the file to S3
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: s3Key,
Body: req,
ContentType: contentType,
ContentLength: contentLength,
}),
);
// Update user image in database
const updatedUser = await userRepo.update(db, user.id, {
image: s3Key,
});
return res.status(200).json({
key: s3Key,
filename: sanitizedFilename,
contentType,
size: contentLength,
user: updatedUser,
});
} catch (error) {
console.error("Avatar upload failed", error);
return res.status(500).json({ error: "Internal server error" });
}
},
);

View File

@@ -1,75 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env as nextRuntimeEnv } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const allowedContentTypes = ["image/jpeg", "image/png"];
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user } = await createNextApiContext(req);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const { filename, contentType } = req.body as {
filename: string;
contentType: string;
};
// Specific to avatar uploads for now
const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/;
if (!filenameRegex.test(filename)) {
return res.status(400).json({ error: "Invalid filename" });
}
if (
typeof contentType !== "string" ||
!allowedContentTypes.includes(contentType)
) {
return res.status(400).json({ error: "Invalid content type" });
}
const credentials =
env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
? {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
}
: undefined;
const client = new S3Client({
region: env.S3_REGION ?? "",
endpoint: env.S3_ENDPOINT ?? "",
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
credentials,
});
const signedUrl = await getSignedUrl(
client,
new PutObjectCommand({
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: filename,
ACL: "public-read",
}),
);
return res.status(200).json({ url: signedUrl, key: filename });
} catch (error) {
return res.status(500).json({ error: (error as Error).message });
}
},
);

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import React from "react";
import { import {
Dialog, Dialog,
DialogBackdrop, DialogBackdrop,
@@ -464,19 +465,37 @@ function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
? stroke.modifiers.map(stringifyModifier) ? stroke.modifiers.map(stringifyModifier)
: []; : [];
modifierStrings.forEach((mod) => { modifierStrings.forEach((mod, index) => {
parts.push(<kbd className={kbdClassName}>{mod}</kbd>); parts.push(
<kbd key={`mod-${index}-${mod}`} className={kbdClassName}>
{mod}
</kbd>,
);
}); });
parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>); parts.push(
<kbd key={`key-${stroke.key}`} className={kbdClassName}>
{stroke.key.toUpperCase()}
</kbd>,
);
return parts; return parts;
}; };
if (shortcut.type === "SEQUENCE") { if (shortcut.type === "SEQUENCE") {
const parts: ReactNode[] = []; const parts: ReactNode[] = [];
shortcut.strokes.forEach((stroke) => { shortcut.strokes.forEach((stroke, strokeIndex) => {
parts.push(...formatStroke(stroke)); const strokeParts = formatStroke(stroke);
// Add stroke index to keys to ensure uniqueness across multiple strokes
const keyedParts = strokeParts.map((part, partIndex) => {
if (React.isValidElement(part)) {
return React.cloneElement(part, {
key: `stroke-${strokeIndex}-${part.key || partIndex}`,
});
}
return part;
});
parts.push(...keyedParts);
}); });
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>; return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
} }

View File

@@ -8,6 +8,8 @@ export async function invalidateCard(
utils: ReturnType<typeof api.useUtils>, utils: ReturnType<typeof api.useUtils>,
cardPublicId: string, cardPublicId: string,
) { ) {
if (!cardPublicId || cardPublicId.length < 12) return;
await Promise.all([ await Promise.all([
utils.card.byId.invalidate({ cardPublicId }), utils.card.byId.invalidate({ cardPublicId }),
utils.card.getActivities.invalidate({ cardPublicId }), utils.card.getActivities.invalidate({ cardPublicId }),

View File

@@ -1,5 +1,3 @@
import { env } from "next-runtime-env";
export const formatToArray = ( export const formatToArray = (
value: string | string[] | undefined, value: string | string[] | undefined,
): string[] => { ): string[] => {
@@ -52,14 +50,5 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
return imageOrKey; return imageOrKey;
} }
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME"); return "";
const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS");
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
if (useVirtualHostedUrls === "true" && storageDomain) {
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
}
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
return `${storageUrl}/${bucket}/${imageOrKey}`;
}; };

View File

@@ -58,7 +58,7 @@ export default function LoginPage() {
</div> </div>
</div> </div>
)} )}
{!isSignUpDisabled && ( {(!isSignUpDisabled || redirect?.startsWith("/invite/")) && (
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000"> <p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
<Trans> <Trans>
Don't have an account?{" "} Don't have an account?{" "}

View File

@@ -28,7 +28,9 @@ export default function SignUpPage() {
setMagicLinkRecipient(recipient); setMagicLinkRecipient(recipient);
}; };
if (isSignUpDisabled) { const isInviteFlow = redirect?.startsWith("/invite/");
if (isSignUpDisabled && !isInviteFlow) {
return ( return (
<> <>
<PageHead title={t`Sign up | kan.bn`} /> <PageHead title={t`Sign up | kan.bn`} />

View File

@@ -7,7 +7,7 @@ import {
HiOutlineStar, HiOutlineStar,
HiStar, HiStar,
} from "react-icons/hi2"; } from "react-icons/hi2";
import { IoArchiveOutline } from "react-icons/io5";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
@@ -17,6 +17,7 @@ import { api } from "~/utils/api";
export default function BoardDropdown({ export default function BoardDropdown({
isTemplate, isTemplate,
isLoading, isLoading,
isArchived,
boardPublicId, boardPublicId,
isFavorite, isFavorite,
boardName, boardName,
@@ -24,28 +25,29 @@ export default function BoardDropdown({
isTemplate: boolean; isTemplate: boolean;
isLoading: boolean; isLoading: boolean;
boardPublicId: string; boardPublicId: string;
isArchived?: boolean;
isFavorite?: boolean; isFavorite?: boolean;
boardName?: string; boardName?: string;
}) { }) {
const { openModal } = useModal(); const { openModal } = useModal();
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
usePermissions();
const utils = api.useUtils(); const utils = api.useUtils();
const handleToggleFavorite = () => {
updateBoard.mutate({
boardPublicId,
favorite: !isFavorite,
});
};
const updateBoard = api.board.update.useMutation({ const updateBoard = api.board.update.useMutation({
onSuccess: (data, variables) => { onSuccess: (_data, variables) => {
void utils.board.all.invalidate(); void utils.board.all.invalidate();
void utils.board.byId.invalidate(); void utils.board.byId.invalidate();
if (variables.isArchived !== undefined) {
// Show popup notification showPopup({
if (variables.favorite !== undefined) { header: variables.isArchived ? t`Board archived` : t`Board unarchived`,
message: variables.isArchived
? t`The board has been archived.`
: t`The board has been unarchived.`,
icon: "success",
});
} else if (variables.favorite !== undefined) {
showPopup({ showPopup({
header: variables.favorite header: variables.favorite
? t`Added to favorites` ? t`Added to favorites`
@@ -66,26 +68,53 @@ export default function BoardDropdown({
}, },
}); });
const handleToggleFavorite = () => {
updateBoard.mutate({
boardPublicId,
favorite: !isFavorite,
});
};
const handleArchiveOrUnarchive = () => {
updateBoard.mutate({
boardPublicId,
isArchived: !isArchived,
});
};
const isArchiveActionPending = updateBoard.isPending;
const items = [ const items = [
...(isTemplate && canCreateBoard ...(isTemplate && canCreateBoard
? [ ? [
{ {
label: t`Make template`, label: t`Make template`,
action: () => openModal("CREATE_TEMPLATE"), action: () => openModal("CREATE_TEMPLATE"),
icon: ( icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" /> <HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
), ),
}, },
] ]
: []), : []),
...(!isTemplate && canEditBoard ...(!isTemplate && canEditBoard
? [ ? [
{ {
label: t`Edit board URL`, label: t`Edit board URL`,
action: () => openModal("UPDATE_BOARD_SLUG"), action: () => openModal("UPDATE_BOARD_SLUG"),
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />, icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
}, },
] ]
: []),
...(!isTemplate && canArchiveBoard
? [
{
label: isArchived ? t`Unarchive board` : t`Archive board`,
action: handleArchiveOrUnarchive,
icon: (
<IoArchiveOutline className="h-[16px] w-[16px] text-dark-900" />
),
},
]
: []), : []),
{ {
label: isFavorite label: isFavorite
@@ -100,22 +129,26 @@ export default function BoardDropdown({
}, },
...(canDeleteBoard ...(canDeleteBoard
? [ ? [
{ {
label: isTemplate ? t`Delete template` : t`Delete board`, label: isTemplate ? t`Delete template` : t`Delete board`,
action: () => openModal("DELETE_BOARD"), action: () => openModal("DELETE_BOARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />, icon: (
}, <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
] ),
},
]
: []), : []),
]; ];
if (items.length === 0) { if (items.length === 0) {
return null; return null;
} }
return ( return (
<Dropdown disabled={isLoading} items={items}> <Dropdown
disabled={isLoading || isArchiveActionPending}
items={items}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" /> <HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown> </Dropdown>
); );

View File

@@ -66,8 +66,8 @@ const Card = ({
const hasDueDate = !!dueDate; const hasDueDate = !!dueDate;
return ( return (
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"> <div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
<span>{title}</span> <span className="break-words">{title}</span>
{labels.length || {labels.length ||
members.length || members.length ||
checklists.length > 0 || checklists.length > 0 ||

View File

@@ -1,24 +1,40 @@
import Link from "next/link"; import { Fragment } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { HiLink } from "react-icons/hi"; import { HiLink } from "react-icons/hi";
import { Tooltip } from "~/components/Tooltip"; import { Tooltip } from "~/components/Tooltip";
import { usePopup } from "~/providers/popup";
const displayBaseUrl =
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? "kan.bn"
: env("NEXT_PUBLIC_BASE_URL");
const linkBaseUrl = env("NEXT_PUBLIC_BASE_URL");
const pathSeparator = (
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600" />
);
const UpdateBoardSlugButton = ({ const UpdateBoardSlugButton = ({
handleOnClick, handleOnClick,
workspaceSlug, workspaceSlug,
boardSlug, boardSlug,
boardPublicId,
visibility,
isLoading, isLoading,
canEdit, canEdit,
}: { }: {
handleOnClick: () => void; handleOnClick: () => void;
workspaceSlug: string; workspaceSlug: string;
boardSlug: string; boardSlug: string;
boardPublicId: string;
visibility: "public" | "private";
isLoading: boolean; isLoading: boolean;
canEdit: boolean; canEdit: boolean;
}) => { }) => {
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>; const { showPopup } = usePopup();
if (isLoading) { if (isLoading) {
return ( return (
@@ -26,44 +42,55 @@ const UpdateBoardSlugButton = ({
); );
} }
if (!workspaceSlug || !boardSlug || !boardPublicId) return <></>;
const isPublic = visibility === "public";
const boardUrl = isPublic
? `${linkBaseUrl}/${workspaceSlug}/${boardSlug}`
: `${linkBaseUrl}/boards/${boardPublicId}`;
const pathSegments = isPublic
? [displayBaseUrl, workspaceSlug, boardSlug]
: [displayBaseUrl, "boards", boardPublicId];
return ( return (
<Tooltip <Tooltip
content={!canEdit && !isLoading ? t`You don't have permission` : undefined} content={!canEdit ? t`You don't have permission` : undefined}
> >
<button <button
onClick={canEdit ? handleOnClick : undefined} onClick={canEdit ? handleOnClick : undefined}
disabled={!canEdit || isLoading} disabled={!canEdit || isLoading}
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex" className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
> >
<div className="flex items-center"> <div className="flex items-center">
<span> {pathSegments.map((segment, i) => (
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" <Fragment key={i}>
? "kan.bn" {i > 0 && pathSeparator}
: env("NEXT_PUBLIC_BASE_URL")} <span>{segment}</span>
</span> </Fragment>
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div> ))}
<span>{workspaceSlug}</span> </div>
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div> <button
<span>{boardSlug}</span> type="button"
</div> onClick={(e) => {
<Link e.stopPropagation();
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`} navigator.clipboard.writeText(boardUrl).then(
target="_blank" () =>
rel="noopener noreferrer" showPopup({
onClick={(e) => { header: t`Link copied`,
e.stopPropagation(); icon: "success",
if (!canEdit) { message: t`Board URL copied to clipboard`,
e.preventDefault(); }),
} ).catch(() => undefined);
}} }}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200" className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
> aria-label={t`Copy board link`}
<HiLink className="h-[13px] w-[13px]" /> >
</Link> <HiLink className="h-[13px] w-[13px]" />
</button> </button>
</button>
</Tooltip> </Tooltip>
); );
}; };
export default UpdateBoardSlugButton; export default UpdateBoardSlugButton;

View File

@@ -156,7 +156,7 @@ export function UpdateBoardSlugForm({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="secondary" variant="secondary"
href="/settings?tab=workspace" href="/settings/workspace"
onClick={closeModal} onClick={closeModal}
> >
{t`Edit workspace URL`} {t`Edit workspace URL`}

View File

@@ -64,7 +64,8 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
direction: "horizontal", direction: "horizontal",
}); });
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions(); const { canCreateList, canEditList, canEditCard, canEditBoard } =
usePermissions();
const { tooltipContent: createListShortcutTooltipContent } = const { tooltipContent: createListShortcutTooltipContent } =
useKeyboardShortcut({ useKeyboardShortcut({
@@ -417,10 +418,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
{...register("name")} {...register("name")}
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined} onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
readOnly={!canEditBoard} readOnly={!canEditBoard}
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem] disabled:cursor-not-allowed" className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none disabled:cursor-not-allowed dark:text-dark-1000 sm:text-[1.2rem]"
/> />
</form> </form>
)} )}
{!boardData && !isLoading && ( {!boardData && !isLoading && (
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1"> <p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
@@ -443,6 +443,8 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isLoading={isLoading} isLoading={isLoading}
workspaceSlug={workspace.slug ?? ""} workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""} boardSlug={boardData?.slug ?? ""}
boardPublicId={boardId ?? ""}
visibility={boardData?.visibility ?? "private"}
canEdit={canEditBoard} canEdit={canEditBoard}
/> />
<VisibilityButton <VisibilityButton
@@ -492,7 +494,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isTemplate={!!isTemplate} isTemplate={!!isTemplate}
isLoading={!boardData} isLoading={!boardData}
boardPublicId={boardId ?? ""} boardPublicId={boardId ?? ""}
workspacePublicId={workspace.publicId} isArchived={boardData?.isArchived ?? false}
isFavorite={boardData?.favorite} isFavorite={boardData?.favorite}
boardName={boardData?.name} boardName={boardData?.name}
/> />

View File

@@ -10,7 +10,7 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) { export function BoardsList({ isTemplate, archived = false }: { isTemplate?: boolean; archived?: boolean }) {
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const { openModal } = useModal(); const { openModal } = useModal();
const { canCreateBoard } = usePermissions(); const { canCreateBoard } = usePermissions();
@@ -26,6 +26,7 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
{ {
workspacePublicId: workspace.publicId, workspacePublicId: workspace.publicId,
type: isTemplate ? "template" : "regular", type: isTemplate ? "template" : "regular",
archived: archived,
}, },
{ enabled: workspace.publicId ? true : false }, { enabled: workspace.publicId ? true : false },
); );
@@ -59,10 +60,10 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" /> <HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950"> <p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
{t`No ${isTemplate ? "templates" : "boards"}`} {archived ? t`No archived boards` : t`No ${isTemplate ? "templates" : "boards"}`}
</p> </p>
<p className="text-[14px] text-light-900 dark:text-dark-900"> <p className="text-[14px] text-light-900 dark:text-dark-900">
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`} {archived ? t`Boards you archive will appear here.` : t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
</p> </p>
</div> </div>
<Tooltip <Tooltip
@@ -109,18 +110,18 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
> >
<div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100"> <div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
<PatternedBackground /> <PatternedBackground />
<button <button
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)} onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100" className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
}`} }`}
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"} aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
> >
{board.favorite ? ( {board.favorite ? (
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" /> <HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
) : ( ) : (
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" /> <HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
)} )}
</button> </button>
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000"> <p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
{board.name} {board.name}
</p> </p>

View File

@@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import { Fragment, useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { FaTrello } from "react-icons/fa"; import { FaGithub, FaTrello } from "react-icons/fa";
import { import {
HiChevronUpDown, HiChevronUpDown,
HiMiniArrowTopRightOnSquare, HiMiniArrowTopRightOnSquare,
@@ -27,12 +27,23 @@ const integrationProviders: Record<
name: "Trello", name: "Trello",
icon: <FaTrello />, icon: <FaTrello />,
}, },
github: {
name: "GitHub",
icon: <FaGithub />,
},
}; };
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => { const SelectSource = ({
handleNextStep,
}: {
handleNextStep: (provider: string) => void;
}) => {
const { data: integrations, refetch: refetchIntegrations } = const { data: integrations, refetch: refetchIntegrations } =
api.integration.providers.useQuery(); api.integration.providers.useQuery();
const { control, handleSubmit } = useForm({ const { data: githubStatus, refetch: refetchGithubStatus } =
api.integration.getGitHubStatus.useQuery();
const { control, handleSubmit, watch } = useForm({
defaultValues: { defaultValues: {
source: integrations?.[0]?.provider ?? "trello", source: integrations?.[0]?.provider ?? "trello",
}, },
@@ -47,23 +58,36 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
}, },
); );
const hasIntegrations = integrations && integrations.length > 0; const availableIntegrations = [
...(integrations ?? []),
...(githubStatus?.connected ? [{ provider: "github" }] : []),
];
const hasIntegrations = availableIntegrations.length > 0;
useEffect(() => { useEffect(() => {
const handleFocus = () => { const handleFocus = () => {
refetchIntegrations(); void refetchIntegrations();
void refetchGithubStatus();
}; };
window.addEventListener("focus", handleFocus); window.addEventListener("focus", handleFocus);
return () => { return () => {
window.removeEventListener("focus", handleFocus); window.removeEventListener("focus", handleFocus);
}; };
}, [refetchIntegrations]); }, [refetchIntegrations, refetchGithubStatus]);
const onSubmit = () => { const onSubmit = () => {
if (!hasIntegrations && trelloUrl) { const selected = watch("source");
window.open(trelloUrl.url, "trello_auth", "height=800,width=600"); if (
selected === "trello" &&
!integrations?.some((i) => i.provider === "trello")
) {
if (trelloUrl)
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
} else if (selected === "github" && !githubStatus?.connected) {
window.open("/settings/integrations", "_blank");
} else { } else {
handleNextStep(); handleNextStep(selected);
} }
}; };
@@ -102,7 +126,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
> >
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-1 text-base text-neutral-900 shadow-lg ring-1 ring-light-600 ring-opacity-5 focus:outline-none dark:bg-dark-300 dark:text-dark-1000 sm:text-sm"> <Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-1 text-base text-neutral-900 shadow-lg ring-1 ring-light-600 ring-opacity-5 focus:outline-none dark:bg-dark-300 dark:text-dark-1000 sm:text-sm">
{hasIntegrations ? ( {hasIntegrations ? (
integrations.map((integration, index) => ( availableIntegrations.map((integration, index) => (
<Listbox.Option <Listbox.Option
key={`source_${index}`} key={`source_${index}`}
className="relative cursor-default select-none px-1" className="relative cursor-default select-none px-1"
@@ -123,18 +147,32 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
</Listbox.Option> </Listbox.Option>
)) ))
) : ( ) : (
<Listbox.Option <>
key="trello_placeholder" <Listbox.Option
className="font-sm relative cursor-default select-none px-1" key="trello_placeholder"
value="trello" className="font-sm relative cursor-default select-none px-1"
> value="trello"
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400"> >
{integrationProviders.trello?.icon} <div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
<span className="ml-2 block truncate text-sm"> {integrationProviders.trello?.icon}
{integrationProviders.trello?.name} <span className="ml-2 block truncate text-sm">
</span> {integrationProviders.trello?.name}
</div> </span>
</Listbox.Option> </div>
</Listbox.Option>
<Listbox.Option
key="github_placeholder"
className="font-sm relative cursor-default select-none px-1"
value="github"
>
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
{integrationProviders.github?.icon}
<span className="ml-2 block truncate text-sm">
{integrationProviders.github?.name}
</span>
</div>
</Listbox.Option>
</>
)} )}
</Listbox.Options> </Listbox.Options>
</Transition> </Transition>
@@ -154,7 +192,159 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined !hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
} }
> >
{hasIntegrations ? t`Select source` : t`Connect Trello`} {hasIntegrations ? t`Select source` : t`Connect`}
</Button>
</div>
</div>
</form>
);
};
const ImportGithub: React.FC = () => {
const utils = api.useUtils();
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const { showPopup } = usePopup();
const [isSelectAllEnabled, setIsSelectAllEnabled] = useState(false);
const refetchBoards = () => utils.board.all.refetch();
const { data: projects, isLoading: projectsLoading } =
api.import.github.getProjects.useQuery();
const {
register: registerProjects,
handleSubmit: handleSubmitProjects,
setValue,
watch,
} = useForm({
defaultValues: Object.fromEntries(
projects?.map((project) => [project.id, true]) ?? [],
),
});
const importProjects = api.import.github.importProjects.useMutation({
onSuccess: async () => {
showPopup({
header: t`Import complete`,
message: t`Your projects have been imported.`,
icon: "success",
});
try {
await refetchBoards();
closeModal();
} catch (e) {
console.log(e);
}
},
onError: () => {
showPopup({
header: t`Import failed`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const projectWatchers = projects?.map((project) => ({
id: project.id,
value: watch(project.id),
}));
const projectCount =
projectWatchers?.filter((w) => w.value === true).length ?? 0;
const onSubmitProjects = (values: Record<string, boolean>) => {
const projectIds = Object.keys(values).filter(
(key) => values[key] === true,
);
importProjects.mutate({
projectIds,
workspacePublicId: workspace.publicId,
});
};
const renderContent = () => {
if (projectsLoading) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1">
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
</div>
);
}
if (!projects?.length) {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-sm text-neutral-500 dark:text-dark-900">
{t`No projects found`}
</p>
</div>
);
}
return projects.map((project) => (
<div key={project.id}>
<label
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
htmlFor={project.id}
>
<input
id={project.id}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
{...registerProjects(project.id)}
/>
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
{project.name}
</span>
</label>
</div>
));
};
return (
<form onSubmit={handleSubmitProjects(onSubmitProjects)}>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Select all`}
isChecked={!!isSelectAllEnabled}
onChange={() => {
const newState = !isSelectAllEnabled;
setIsSelectAllEnabled(newState);
for (const project of projects ?? []) {
setValue(project.id, newState);
}
}}
/>
<div className="space-x-2">
<Button
type="submit"
isLoading={importProjects.isPending}
disabled={
importProjects.isPending ||
projectsLoading ||
!projects?.length ||
!projects.some(
(project) =>
projectWatchers?.find((w) => w.id === project.id)?.value ===
true,
)
}
>
<Trans>
<Plural
value={projectCount}
one={`Import project (1)`}
other={`Import projects (${projectCount})`}
/>
</Trans>
</Button> </Button>
</div> </div>
</div> </div>
@@ -213,7 +403,7 @@ const ImportTrello: React.FC = () => {
value: watch(board.id), value: watch(board.id),
})); }));
const boardCount = boardWatchers?.filter((w) => w.value === true).length || 0; const boardCount = boardWatchers?.filter((w) => w.value === true).length ?? 0;
const onSubmitBoards = (values: Record<string, boolean>) => { const onSubmitBoards = (values: Record<string, boolean>) => {
const boardIds = Object.keys(values).filter((key) => values[key] === true); const boardIds = Object.keys(values).filter((key) => values[key] === true);
@@ -267,7 +457,7 @@ const ImportTrello: React.FC = () => {
return ( return (
<form onSubmit={handleSubmitBoards(onSubmitBoards)}> <form onSubmit={handleSubmitBoards(onSubmitBoards)}>
<div className="h-[105px] overflow-scroll px-5">{renderContent()}</div> <div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle <Toggle
@@ -277,7 +467,7 @@ const ImportTrello: React.FC = () => {
const newState = !isSelectAllEnabled; const newState = !isSelectAllEnabled;
setIsSelectAllEnabled(newState); setIsSelectAllEnabled(newState);
for (const board of boards || []) { for (const board of boards ?? []) {
setValue(board.id, newState); setValue(board.id, newState);
} }
}} }}
@@ -313,6 +503,7 @@ const ImportTrello: React.FC = () => {
export function ImportBoardsForm() { export function ImportBoardsForm() {
const { closeModal } = useModal(); const { closeModal } = useModal();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [provider, setProvider] = useState<string | null>(null);
return ( return (
<div> <div>
@@ -339,8 +530,16 @@ export function ImportBoardsForm() {
</button> </button>
</div> </div>
{step === 1 && <SelectSource handleNextStep={() => setStep(step + 1)} />} {step === 1 && (
{step === 2 && <ImportTrello />} <SelectSource
handleNextStep={(p) => {
setProvider(p);
setStep(step + 1);
}}
/>
)}
{step === 2 && provider === "trello" && <ImportTrello />}
{step === 2 && provider === "github" && <ImportGithub />}
</div> </div>
); );
} }

View File

@@ -23,6 +23,18 @@ export const getTemplates = (): Template[] => [
lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`], lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`],
labels: [t`Bug`, t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`], labels: [t`Bug`, t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
}, },
{
id: "roadmap-basic",
name: t`Basic Roadmap`,
lists: [t`Requested`, t`Planned`, t`In Progress`, t`Done`],
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
},
{
id: "roadmap-extended",
name: t`Extended Roadmap`,
lists: [t`Requested`, t`Under Review`, t`Planned`, t`In Progress`, t`Done`, t`Rejected`],
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
},
{ {
id: "content-creation", id: "content-creation",
name: t`Content Creation`, name: t`Content Creation`,

View File

@@ -1,5 +1,12 @@
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2"; import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2";
import { useState } from "react";
import Button from "~/components/Button"; import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
@@ -15,9 +22,15 @@ import { BoardsList } from "./components/BoardsList";
import { ImportBoardsForm } from "./components/ImportBoardsForm"; import { ImportBoardsForm } from "./components/ImportBoardsForm";
import { NewBoardForm } from "./components/NewBoardForm"; import { NewBoardForm } from "./components/NewBoardForm";
const boardsTabs = [
{ key: "boards" as const, label: t`Active` },
{ key: "archived" as const, label: t`Archived` },
];
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) { export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
const { openModal, modalContentType, isOpen } = useModal(); const { openModal, modalContentType, isOpen } = useModal();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
const { canCreateBoard } = usePermissions(); const { canCreateBoard } = usePermissions();
const { tooltipContent: createModalShortcutTooltipContent } = const { tooltipContent: createModalShortcutTooltipContent } =
@@ -34,7 +47,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
<PageHead <PageHead
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? t`Workspace`}`} title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? t`Workspace`}`}
/> />
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12"> <div className="m-auto h-full max-w-[1100px] p-8 px-5 md:px-28 md:py-12">
<div className="relative z-10 mb-8 flex w-full items-center justify-between"> <div className="relative z-10 mb-8 flex w-full items-center justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]"> <h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`${isTemplate ? "Templates" : "Boards"}`} {t`${isTemplate ? "Templates" : "Boards"}`}
@@ -115,9 +128,79 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
</Modal> </Modal>
</> </>
<div className="flex h-full flex-row"> {!isTemplate ? (
<BoardsList isTemplate={!!isTemplate} /> <div className="flex h-full w-full flex-col">
</div> <div className="focus:outline-none">
<div className="sm:hidden">
<Listbox
value={activeTab}
onChange={(tab) => setActiveTab(tab)}
>
<div className="relative mb-4">
<ListboxButton className="w-full appearance-none rounded-md border-0 bg-light-50 py-3 pl-3 pr-10 text-left text-sm font-semibold text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
{boardsTabs.find((tab) => tab.key === activeTab)?.label ??
"Select a tab"}
<HiChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
/>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 w-full rounded-md bg-light-50 py-1 text-sm shadow-lg ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:ring-dark-300">
{boardsTabs.map((tab) => (
<ListboxOption
key={tab.key}
value={tab.key}
className={({ selected }) =>
`relative cursor-pointer select-none py-2 pl-3 pr-9 ${selected
? "font-bold text-light-1000 dark:text-dark-1000"
: "font-normal text-light-1000 dark:text-dark-1000"
}`
}
>
{tab.label}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
</div>
<div className="hidden sm:block">
<div>
<nav
aria-label="Tabs"
className="-mb-px flex space-x-8 focus:outline-none"
>
{boardsTabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={`whitespace-nowrap px-1 py-0 mt-2 mb-8 text-sm font-semibold transition-colors focus:outline-none ${activeTab === tab.key
? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000"
: "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950"
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
</div>
</div>
<div className="flex h-full flex-row focus:outline-none">
{activeTab === "boards" && (
<BoardsList isTemplate={false} archived={false} />
)}
{activeTab === "archived" && (
<BoardsList isTemplate={false} archived={true} />
)}
</div>
</div>
) : (
<div className="flex h-full flex-row">
<BoardsList isTemplate={!!isTemplate} />
</div>
)}
</div> </div>
</> </>
); );

View File

@@ -8,6 +8,7 @@ import {
HiOutlineArrowRight, HiOutlineArrowRight,
HiOutlineCheckCircle, HiOutlineCheckCircle,
HiOutlineClock, HiOutlineClock,
HiOutlinePaperClip,
HiOutlinePencil, HiOutlinePencil,
HiOutlinePlus, HiOutlinePlus,
HiOutlineTag, HiOutlineTag,
@@ -31,29 +32,50 @@ import Comment from "./Comment";
type ActivityType = type ActivityType =
NonNullable<GetCardByIdOutput>["activities"][number]["type"]; NonNullable<GetCardByIdOutput>["activities"][number]["type"];
type ActivityWithMergedLabels =
GetCardActivitiesOutput["activities"][number] & {
mergedLabels?: string[];
attachment?: {
publicId: string;
filename: string;
originalFilename: string;
} | null;
};
const truncate = (value: string | null, maxLength = 50) => { const truncate = (value: string | null, maxLength = 50) => {
if (!value) return value; if (!value) return value;
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value; return value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value;
}; };
const getUserDisplayName = (
user: { name?: string | null; email?: string | null } | null | undefined,
): string => {
if (user?.name?.trim()) return user.name;
if (user?.email) return user.email;
return t`Member`;
};
const getActivityText = ({ const getActivityText = ({
type, type,
toTitle, toTitle,
fromList, fromList,
toList, toList,
memberName, memberName,
memberEmail,
isSelf, isSelf,
label, label,
fromTitle, fromTitle,
toDueDate, toDueDate,
dateLocale, dateLocale,
mergedLabels, mergedLabels,
attachmentName,
}: { }: {
type: ActivityType; type: ActivityType;
toTitle: string | null; toTitle: string | null;
fromList: string | null; fromList: string | null;
toList: string | null; toList: string | null;
memberName: string | null; memberName: string | null;
memberEmail: string | null;
isSelf: boolean; isSelf: boolean;
label: string | null; label: string | null;
fromTitle?: string | null; fromTitle?: string | null;
@@ -61,7 +83,9 @@ const getActivityText = ({
toDueDate?: Date | null; toDueDate?: Date | null;
dateLocale: DateFnsLocale; dateLocale: DateFnsLocale;
mergedLabels?: string[]; mergedLabels?: string[];
attachmentName?: string | null;
}) => { }) => {
const displayName = memberName ?? memberEmail ?? t`Member`;
const TextHighlight = ({ children }: { children: React.ReactNode }) => ( const TextHighlight = ({ children }: { children: React.ReactNode }) => (
<span className="font-medium text-light-1000 dark:text-dark-1000"> <span className="font-medium text-light-1000 dark:text-dark-1000">
{children} {children}
@@ -113,6 +137,8 @@ const getActivityText = ({
"card.updated.checklist.item.completed": t`completed a checklist item`, "card.updated.checklist.item.completed": t`completed a checklist item`,
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`, "card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
"card.updated.checklist.item.deleted": t`deleted a checklist item`, "card.updated.checklist.item.deleted": t`deleted a checklist item`,
"card.updated.attachment.added": t`added an attachment`,
"card.updated.attachment.removed": t`removed an attachment`,
"card.updated.dueDate.added": t`set the due date`, "card.updated.dueDate.added": t`set the due date`,
"card.updated.dueDate.updated": t`updated the due date`, "card.updated.dueDate.updated": t`updated the due date`,
"card.updated.dueDate.removed": t`removed the due date`, "card.updated.dueDate.removed": t`removed the due date`,
@@ -139,23 +165,23 @@ const getActivityText = ({
); );
} }
if (type === "card.updated.member.added" && memberName) { if (type === "card.updated.member.added" && displayName) {
if (isSelf) return <Trans>self-assigned the card</Trans>; if (isSelf) return <Trans>self-assigned the card</Trans>;
return ( return (
<Trans> <Trans>
assigned <TextHighlight>{truncate(memberName)}</TextHighlight> to the assigned <TextHighlight>{truncate(displayName)}</TextHighlight> to the
card card
</Trans> </Trans>
); );
} }
if (type === "card.updated.member.removed" && memberName) { if (type === "card.updated.member.removed" && displayName) {
if (isSelf) return <Trans>unassigned themselves from the card</Trans>; if (isSelf) return <Trans>unassigned themselves from the card</Trans>;
return ( return (
<Trans> <Trans>
unassigned <TextHighlight>{truncate(memberName)}</TextHighlight> from unassigned <TextHighlight>{truncate(displayName)}</TextHighlight> from
the card the card
</Trans> </Trans>
); );
@@ -245,6 +271,27 @@ const getActivityText = ({
); );
} }
if (type === "card.updated.attachment.added") {
const filename = attachmentName ?? toTitle;
if (!filename) return baseText;
return (
<Trans>
added an attachment <TextHighlight>{truncate(filename)}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.attachment.removed") {
const filename = attachmentName ?? fromTitle;
if (!filename) return baseText;
return (
<Trans>
removed an attachment{" "}
<TextHighlight>{truncate(filename)}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.dueDate.added" && toDueDate) { if (type === "card.updated.dueDate.added" && toDueDate) {
const showYear = !isSameYear(toDueDate, new Date()); const showYear = !isSameYear(toDueDate, new Date());
const formattedDate = format( const formattedDate = format(
@@ -297,6 +344,8 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />, "card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />, "card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
"card.updated.checklist.item.deleted": <HiOutlineTrash />, "card.updated.checklist.item.deleted": <HiOutlineTrash />,
"card.updated.attachment.added": <HiOutlinePaperClip />,
"card.updated.attachment.removed": <HiOutlinePaperClip />,
"card.updated.dueDate.added": <HiOutlineClock />, "card.updated.dueDate.added": <HiOutlineClock />,
"card.updated.dueDate.updated": <HiOutlineClock />, "card.updated.dueDate.updated": <HiOutlineClock />,
"card.updated.dueDate.removed": <HiOutlineClock />, "card.updated.dueDate.removed": <HiOutlineClock />,
@@ -330,7 +379,7 @@ const ActivityList = ({
isAdmin?: boolean; isAdmin?: boolean;
isViewOnly?: boolean; isViewOnly?: boolean;
}) => { }) => {
const { dateLocale, locale } = useLocalisation(); const { dateLocale } = useLocalisation();
const { data: sessionData } = authClient.useSession(); const { data: sessionData } = authClient.useSession();
const utils = api.useUtils(); const utils = api.useUtils();
const [allActivities, setAllActivities] = useState< const [allActivities, setAllActivities] = useState<
@@ -352,7 +401,7 @@ const ActivityList = ({
limit: ACTIVITIES_PAGE_SIZE, limit: ACTIVITIES_PAGE_SIZE,
}, },
{ {
enabled: !!cardPublicId, enabled: !!cardPublicId && cardPublicId.length >= 12,
}, },
); );
@@ -380,25 +429,21 @@ const ActivityList = ({
cursor: nextCursor, cursor: nextCursor,
}); });
if (nextPage) { const existingIds = new Set(
const existingIds = new Set( currentActivities.map((a) => a.publicId),
currentActivities.map((a) => a.publicId), );
); const newActivities = nextPage.activities.filter(
const newActivities = nextPage.activities.filter( (a: { publicId: string }) => !existingIds.has(a.publicId),
(a: { publicId: string }) => !existingIds.has(a.publicId), );
); currentActivities = [...currentActivities, ...newActivities];
currentActivities = [...currentActivities, ...newActivities]; currentHasMore = nextPage.hasMore;
currentHasMore = nextPage.hasMore;
} else {
break;
}
} }
setAllActivities(currentActivities); setAllActivities(currentActivities);
setHasMore(false); setHasMore(false);
}; };
fetchAllRemaining(); void fetchAllRemaining();
} else { } else {
setAllActivities(firstPageData.activities); setAllActivities(firstPageData.activities);
setHasMore(firstPageData.hasMore); setHasMore(firstPageData.hasMore);
@@ -425,17 +470,15 @@ const ActivityList = ({
cursor: nextCursor, cursor: nextCursor,
}); });
if (nextPage) { const existingIds = new Set(allActivities.map((a) => a.publicId));
const existingIds = new Set(allActivities.map((a) => a.publicId)); const newActivities = nextPage.activities.filter(
const newActivities = nextPage.activities.filter( (a: { publicId: string }) => !existingIds.has(a.publicId),
(a: { publicId: string }) => !existingIds.has(a.publicId), );
); setAllActivities((prev) => [...prev, ...newActivities]);
setAllActivities((prev) => [...prev, ...newActivities]); setHasMore(nextPage.hasMore);
setHasMore(nextPage.hasMore);
if (!nextPage.hasMore) { if (!nextPage.hasMore) {
isFullyExpandedRef.current = true; isFullyExpandedRef.current = true;
}
} }
} finally { } finally {
setIsLoadingMore(false); setIsLoadingMore(false);
@@ -455,13 +498,17 @@ 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,
memberEmail: activity.member?.user?.email ?? null,
isSelf: activity.member?.user?.id === sessionData?.user.id, isSelf: activity.member?.user?.id === sessionData?.user.id,
label: activity.label?.name ?? null, label: activity.label?.name ?? null,
fromTitle: activity.fromTitle ?? null, fromTitle: activity.fromTitle ?? null,
fromDueDate: activity.fromDueDate ?? null, fromDueDate: activity.fromDueDate ?? null,
toDueDate: activity.toDueDate ?? null, toDueDate: activity.toDueDate ?? null,
dateLocale: dateLocale, dateLocale: dateLocale,
mergedLabels: (activity as any).mergedLabels, mergedLabels: (activity as ActivityWithMergedLabels).mergedLabels,
attachmentName:
(activity as ActivityWithMergedLabels).attachment?.originalFilename ??
null,
}); });
if (activity.type === "card.updated.comment.added") if (activity.type === "card.updated.comment.added")
@@ -508,7 +555,7 @@ const ActivityList = ({
)} )}
</div> </div>
<p className="text-sm"> <p className="text-sm">
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span> <span className="font-medium dark:text-dark-1000">{`${getUserDisplayName(activity.user)} `}</span>
<span className="space-x-1 text-light-900 dark:text-dark-800"> <span className="space-x-1 text-light-900 dark:text-dark-800">
{activityText} {activityText}
</span> </span>

View File

@@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge";
import Button from "~/components/Button"; import Button from "~/components/Button";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { env } from "next-runtime-env";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation"; import { invalidateCard } from "~/utils/cardInvalidation";
@@ -18,62 +19,33 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation(); const uploadFile = async (file: File) => {
const confirmAttachment = api.attachment.confirm.useMutation({ setUploading(true);
onSuccess: async () => {
try {
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
const response = await fetch(
`${baseUrl}/api/upload/attachment?cardPublicId=${encodeURIComponent(cardPublicId)}`,
{
method: "POST",
headers: {
"Content-Type": file.type,
"x-original-filename": file.name,
},
body: file,
},
);
if (!response.ok) {
throw new Error("Upload failed");
}
await invalidateCard(utils, cardPublicId); await invalidateCard(utils, cardPublicId);
showPopup({ showPopup({
header: t`Attachment uploaded`, header: t`Attachment uploaded`,
message: t`Your file has been uploaded successfully.`, message: t`Your file has been uploaded successfully.`,
icon: "success", icon: "success",
}); });
},
onError: () => {
showPopup({
header: t`Upload failed`,
message: t`Failed to upload attachment. Please try again.`,
icon: "error",
});
},
onSettled: () => {
setUploading(false);
},
});
const uploadFile = async (file: File) => {
setUploading(true);
try {
// Generate presigned URL
const { url, key } = await generateUploadUrl.mutateAsync({
cardPublicId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// Upload file to S3
const uploadResponse = await fetch(url, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("Upload failed");
}
// Confirm attachment in database
await confirmAttachment.mutateAsync({
cardPublicId,
s3Key: key,
filename: file.name,
originalFilename: file.name,
contentType: file.type,
size: file.size,
});
} catch { } catch {
showPopup({ showPopup({
header: t`Upload failed`, header: t`Upload failed`,

View File

@@ -1,12 +1,13 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { useState } from "react"; import { useState } from "react";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2"; import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Editor from "~/components/Editor";
import type { WorkspaceMember } from "~/components/Editor";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
@@ -29,7 +30,6 @@ const Comment = ({
createdAt, createdAt,
comment, comment,
isAuthor, isAuthor,
isAdmin,
isEdited = false, isEdited = false,
isViewOnly = false, isViewOnly = false,
}: { }: {
@@ -42,7 +42,6 @@ const Comment = ({
createdAt: string; createdAt: string;
comment: string | undefined; comment: string | undefined;
isAuthor: boolean; isAuthor: boolean;
isAdmin: boolean;
isEdited: boolean; isEdited: boolean;
isViewOnly: boolean; isViewOnly: boolean;
}) => { }) => {
@@ -57,6 +56,30 @@ const Comment = ({
}, },
}); });
const { data: cardData } = api.card.byId.useQuery(
{
cardPublicId,
},
{
enabled: !!cardPublicId && cardPublicId.length >= 12,
},
);
const workspaceMembers: WorkspaceMember[] =
cardData?.list.board.workspace.members
.filter((member) => member.email)
.map((member) => ({
publicId: member.publicId,
email: member.email,
user: member.user
? {
id: member.user.id,
name: member.user.name ?? null,
image: member.user.image ?? null,
}
: null,
})) ?? [];
if (!publicId) return null; if (!publicId) return null;
const updateCommentMutation = api.card.updateComment.useMutation({ const updateCommentMutation = api.card.updateComment.useMutation({
@@ -142,21 +165,28 @@ const Comment = ({
)} )}
</div> </div>
{!isEditing ? ( {!isEditing ? (
<ContentEditable <div className="mt-2">
html={comment ?? ""} <Editor
disabled={true} content={comment ?? null}
className="break-anywhere mt-2 text-sm" readOnly={true}
/> workspaceMembers={workspaceMembers}
enableYouTubeEmbed={false}
disableHeadings={true}
/>
</div>
) : ( ) : (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<ContentEditable <div className="mt-2">
placeholder={t`Add a comment...`} <Editor
html={watch("comment")} content={watch("comment")}
disabled={false} onChange={(value) => setValue("comment", value)}
onChange={(e) => setValue("comment", e.target.value)} workspaceMembers={workspaceMembers}
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-sm text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6" enableYouTubeEmbed={false}
/> placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
<div className="flex justify-end space-x-2"> disableHeadings={true}
/>
</div>
<div className="flex justify-end space-x-2 mt-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"

View File

@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { import {
HiEllipsisHorizontal, HiEllipsisHorizontal,
HiLink,
HiOutlineCheckCircle, HiOutlineCheckCircle,
HiOutlineTrash, HiOutlineTrash,
} from "react-icons/hi2"; } from "react-icons/hi2";
@@ -10,18 +11,54 @@ import { authClient } from "@kan/auth/client";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
export default function CardDropdown({ export default function CardDropdown({
cardPublicId,
isTemplate,
boardPublicId,
cardCreatedBy, cardCreatedBy,
}: { }: {
cardPublicId: string;
isTemplate?: boolean;
boardPublicId?: string;
cardCreatedBy?: string | null; cardCreatedBy?: string | null;
}) { }) {
const { openModal } = useModal(); const { openModal } = useModal();
const { showPopup } = usePopup();
const { canEditCard, canDeleteCard } = usePermissions(); const { canEditCard, canDeleteCard } = usePermissions();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy; const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
const handleCopyCardLink = async () => {
const path =
isTemplate && boardPublicId
? `/templates/${boardPublicId}/cards/${cardPublicId}`
: `/cards/${cardPublicId}`;
const url = `${window.location.origin}${path}`;
try {
await navigator.clipboard.writeText(url);
showPopup({
header: t`Link copied`,
icon: "success",
message: t`Card URL copied to clipboard`,
});
} catch (error) {
console.error(error);
showPopup({
header: t`Unable to copy link`,
icon: "error",
message: t`Please try again.`,
});
}
};
const items = [ const items = [
{
label: t`Copy card link`,
action: handleCopyCardLink,
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
...(canEditCard ...(canEditCard
? [ ? [
{ {

View File

@@ -1,8 +1,9 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiOutlineArrowUp } from "react-icons/hi2"; import { HiOutlineArrowUp } from "react-icons/hi2";
import Editor from "~/components/Editor";
import type { WorkspaceMember } from "~/components/Editor";
import LoadingSpinner from "~/components/LoadingSpinner"; import LoadingSpinner from "~/components/LoadingSpinner";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
@@ -13,7 +14,13 @@ interface FormValues {
comment: string; comment: string;
} }
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => { const NewCommentForm = ({
cardPublicId,
workspaceMembers,
}: {
cardPublicId: string;
workspaceMembers: WorkspaceMember[];
}) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { canCreateComment } = usePermissions(); const { canCreateComment } = usePermissions();
@@ -23,10 +30,6 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
}, },
}); });
const queryParams = {
cardPublicId,
};
const addCommentMutation = api.card.addComment.useMutation({ const addCommentMutation = api.card.addComment.useMutation({
onError: (_error, _newList) => { onError: (_error, _newList) => {
showPopup({ showPopup({
@@ -57,18 +60,13 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="flex w-full max-w-[800px] flex-col rounded-xl border border-light-600 bg-light-100 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6" className="flex w-full max-w-[800px] flex-col rounded-xl border border-light-600 bg-light-100 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
> >
<ContentEditable <Editor
placeholder={t`Add a comment...`} content={watch("comment")}
html={watch("comment")} onChange={(value) => setValue("comment", value)}
disabled={false} workspaceMembers={workspaceMembers}
onChange={(e) => setValue("comment", e.target.value)} enableYouTubeEmbed={false}
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" placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
onKeyDown={async (e) => { disableHeadings={true}
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<button <button

View File

@@ -3,8 +3,11 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { IoChevronForwardSharp } from "react-icons/io5"; import { IoChevronForwardSharp } from "react-icons/io5";
import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
@@ -14,8 +17,6 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal"; import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { authClient } from "@kan/auth/client";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
@@ -53,9 +54,10 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0] ? router.query.cardId[0]
: router.query.cardId; : router.query.cardId;
const { data: card } = api.card.byId.useQuery({ const { data: card } = api.card.byId.useQuery(
cardPublicId: cardId ?? "", { cardPublicId: cardId ?? "" },
}); { enabled: !!cardId && cardId.length >= 12 },
);
const isCreator = card?.createdBy && session?.user.id === card.createdBy; const isCreator = card?.createdBy && session?.user.id === card.createdBy;
const canEdit = canEditCard || isCreator; const canEdit = canEditCard || isCreator;
@@ -166,7 +168,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
const { const {
modalContentType, modalContentType,
entityId, entityId,
openModal,
getModalState, getModalState,
clearModalState, clearModalState,
isOpen, isOpen,
@@ -184,9 +185,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0] ? router.query.cardId[0]
: router.query.cardId; : router.query.cardId;
const { data: card, isLoading } = api.card.byId.useQuery({ const { data: card, isLoading } = api.card.byId.useQuery(
cardPublicId: cardId ?? "", { cardPublicId: cardId ?? "" },
}); { enabled: !!cardId && cardId.length >= 12 },
);
const isCreator = card?.createdBy && session?.user.id === card.createdBy; const isCreator = card?.createdBy && session?.user.id === card.createdBy;
const canEdit = canEditCard || isCreator; const canEdit = canEditCard || isCreator;
@@ -196,8 +198,24 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
}; };
const board = card?.list.board; const board = card?.list.board;
const workspaceMembers = board?.workspace.members;
const boardId = board?.publicId; const boardId = board?.publicId;
const editorWorkspaceMembers =
workspaceMembers
?.filter((member) => member.email)
.map((member) => ({
publicId: member.publicId,
email: member.email,
user: member.user
? {
id: member.user.id,
name: member.user.name ?? null,
image: member.user.image ?? null,
}
: null,
})) ?? [];
const updateCard = api.card.update.useMutation({ const updateCard = api.card.update.useMutation({
onError: () => { onError: () => {
showPopup({ showPopup({
@@ -315,7 +333,19 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
</Link> </Link>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Dropdown cardCreatedBy={card?.createdBy} /> <Dropdown
cardPublicId={cardId}
isTemplate={isTemplate}
boardPublicId={boardId}
cardCreatedBy={card?.createdBy}
/>
<Link
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
className="flex h-7 w-7 items-center justify-center rounded-[5px] text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200"
aria-label={t`Close`}
>
<HiXMark className="h-4 w-4" />
</Link>
</div> </div>
</> </>
)} )}
@@ -372,9 +402,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<div className="mt-2"> <div className="mt-2">
<Editor <Editor
content={card.description} content={card.description}
onChange={canEdit ? (e) => setValue("description", e) : undefined} onChange={
onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined} canEdit
workspaceMembers={board?.workspace.members ?? []} ? (e) => setValue("description", e)
: undefined
}
onBlur={
canEdit ? () => handleSubmit(onSubmit)() : undefined
}
workspaceMembers={workspaceMembers ?? []}
readOnly={!canEdit} readOnly={!canEdit}
/> />
</div> </div>
@@ -418,7 +454,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
</div> </div>
{!isTemplate && ( {!isTemplate && (
<div className="mt-6"> <div className="mt-6">
<NewCommentForm cardPublicId={cardId} /> <NewCommentForm
cardPublicId={cardId}
workspaceMembers={editorWorkspaceMembers}
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -37,7 +37,7 @@ export default function MembersPage() {
const { data, isLoading } = api.workspace.byId.useQuery( const { data, isLoading } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId }, { workspacePublicId: workspace.publicId },
// { enabled: workspace?.publicId ? true : false }, { enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
); );
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
@@ -48,9 +48,11 @@ export default function MembersPage() {
const updateRoleMutation = api.member.updateRole.useMutation({ const updateRoleMutation = api.member.updateRole.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.workspace.byId.invalidate({ if (workspace.publicId && workspace.publicId.length >= 12) {
workspacePublicId: workspace.publicId, await utils.workspace.byId.invalidate({
}); workspacePublicId: workspace.publicId,
});
}
showPopup({ showPopup({
header: t`Role updated`, header: t`Role updated`,
@@ -124,7 +126,6 @@ export default function MembersPage() {
name={memberName ?? ""} name={memberName ?? ""}
email={memberEmail ?? ""} email={memberEmail ?? ""}
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined} imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
icon={showPendingIcon ? "?" : undefined}
/> />
)} )}
</div> </div>

View File

@@ -1,293 +1,539 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import React, { useEffect, useRef, useState } from "react";
import { HiCheckCircle, HiXCircle } from "react-icons/hi2"; import { HiCheckCircle, HiXCircle } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
interface Feature {
key: string;
label: string;
kan: boolean | string;
trello: boolean | string;
}
interface Section {
name: string;
features: Feature[];
}
type FrequencyValue = "monthly" | "annually"; type FrequencyValue = "monthly" | "annually";
const CellValue = ({ value }: { value: boolean | string }) => { interface PlanFeature {
if (typeof value === "string") { key: string;
return ( label: string;
<span className="text-sm text-dark-900 dark:text-dark-900">{value}</span> free: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
); teams: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
} pro: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
return value ? ( enterprise: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
<HiCheckCircle className="h-5 w-5 text-light-950 dark:text-dark-1000" /> }
) : (
<HiXCircle className="h-5 w-5 text-light-700 dark:text-dark-600" /> interface FeatureSection {
); name: string;
}; features: PlanFeature[];
}
const FeatureComparisonTable = ({ const FeatureComparisonTable = ({
frequencyValue, frequencyValue: _frequencyValue,
}: { }: {
frequencyValue: FrequencyValue; frequencyValue: FrequencyValue;
}) => { }) => {
const sections: Section[] = [ // Keep pricing column headers visible as you scroll
const containerRef = useRef<HTMLDivElement | null>(null);
const [isHeaderFixed, setIsHeaderFixed] = useState(false);
const [headerRect, setHeaderRect] = useState<{ left: number; width: number }>({
left: 0,
width: 0,
});
useEffect(() => {
const HEADER_OFFSET = 64; // px; matches fixed top nav height
const handleScroll = () => {
if (!containerRef.current) return;
// Hide fixed header on mobile (screens smaller than 620px)
if (window.innerWidth < 620) {
setIsHeaderFixed(false);
return;
}
const rect = containerRef.current.getBoundingClientRect();
const pastHeader = rect.top <= HEADER_OFFSET;
const aboveBottom = rect.bottom > HEADER_OFFSET + 40;
const shouldFix = pastHeader && aboveBottom;
setIsHeaderFixed(shouldFix);
if (shouldFix) {
setHeaderRect({
left: rect.left,
width: rect.width,
});
}
};
handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleScroll);
};
}, []);
const planSpecificLimits: PlanFeature[] = [
{ {
name: t`Core features`, key: "boards",
features: [ label: t`Boards`,
{ free: { text: t`Unlimited boards`, highlightFirstWord: true },
key: "ulimited-workspaces", teams: { text: t`Unlimited boards`, highlightFirstWord: true },
label: t`Unlimited workspaces`, pro: { text: t`Unlimited boards`, highlightFirstWord: true },
kan: true, enterprise: { text: t`Unlimited boards`, highlightFirstWord: true },
trello: false,
},
{
key: "unlimited-boards",
label: t`Unlimited boards`,
kan: true,
trello: false,
},
{
key: "unlimited-lists",
label: t`Unlimited lists`,
kan: true,
trello: true,
},
{
key: "unlimited-cards",
label: t`Unlimited cards`,
kan: true,
trello: true,
},
{
key: "activity-log",
label: t`Activity log`,
kan: true,
trello: true,
},
{
key: "templates",
label: t`Custom templates`,
kan: true,
trello: false,
},
{ key: "labels", label: t`Labels & filters`, kan: true, trello: true },
{ key: "checklists", label: t`Checklists`, kan: true, trello: true },
{
key: "import",
label: t`Import from Trello`,
kan: true,
trello: false,
},
{
key: "visibility",
label: t`Board visibility`,
kan: true,
trello: true,
},
{
key: "search",
label: t`Intelligent search`,
kan: true,
trello: true,
},
{
key: "workspace-url",
label: t`Custom workspace link`,
kan: true,
trello: false,
},
],
}, },
{ {
name: t`Teams`, key: "members",
features: [ label: t`Members`,
{ free: { text: t`1 user`, highlightFirstWord: true },
key: "pricing", teams: { text: t`Per-seat pricing`, highlightFirstWord: true },
label: t`Price per user/month`, pro: { text: t`Unlimited members`, highlightFirstWord: true },
kan: frequencyValue === "monthly" ? `$10.00` : `$8.00`, enterprise: { text: t`Unlimited members`, highlightFirstWord: true },
trello: frequencyValue === "monthly" ? `$12.00` : `$10.00`, },
}, {
{ key: "file-uploads",
key: "comments", label: t`File uploads`,
label: t`Comments & mentions`, free: { text: t`10mb file uploads`, highlightFirstWord: true },
kan: true, teams: { text: t`Unlimited file uploads`, highlightFirstWord: true },
trello: true, pro: { text: t`Unlimited file uploads`, highlightFirstWord: true },
}, enterprise: { text: t`Unlimited file uploads`, highlightFirstWord: true },
{ },
key: "assignments", {
label: t`Assignees`, key: "workspace-username",
kan: true, label: t`Workspace username`,
trello: true, free: { text: t`Default username`, highlightFirstWord: true },
}, teams: { text: t`Default username`, highlightFirstWord: true },
{ pro: { text: t`Custom username`, highlightFirstWord: true },
key: "sharing", enterprise: { text: t`Custom username`, highlightFirstWord: true },
label: t`Board sharing & invites`, },
kan: true, ];
trello: true,
}, const coreFeatures: PlanFeature[] = [
{ {
key: "invite-links", key: "checklists",
label: t`Invite links`, label: t`Checklists`,
kan: true, free: true,
trello: true, teams: true,
}, pro: true,
], enterprise: true,
},
{
key: "custom-templates",
label: t`Board templates`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "labels-filters",
label: t`Labels & filters`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "board-visibility",
label: t`Board visibility`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "search",
label: t`Intelligent search`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "activity-log",
label: t`Activity log`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "comments",
label: t`Comments`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "roles-permissions",
label: t`Roles & permissions`,
free: false,
teams: false,
pro: true,
enterprise: true,
},
{
key: "custom-branding",
label: t`Custom branding`,
free: false,
teams: false,
pro: true,
enterprise: true,
},
];
const collaborationFeatures: PlanFeature[] = [
{
key: "mentions",
label: t`Mentions`,
free: false,
teams: true,
pro: true,
enterprise: true,
},
{
key: "email-notifications",
label: t`Email notifications`,
free: false,
teams: true,
pro: true,
enterprise: true,
},
{
key: "assignees",
label: t`Assignees`,
free: false,
teams: true,
pro: true,
enterprise: true,
},
{
key: "invite-links",
label: t`Invite links`,
free: false,
teams: true,
pro: true,
enterprise: true,
},
];
const importsFeatures: PlanFeature[] = [
{
key: "import-export",
label: t`Trello`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
];
const platformFeatures: PlanFeature[] = [
{
key: "open-source",
label: t`Open source`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "rest-api",
label: t`API`,
free: { text: t`Limited API access` },
teams: { text: t`Full API access`, highlight: true },
pro: { text: t`Full API access`, highlight: true },
enterprise: { text: t`Full API access`, highlight: true },
},
{
key: "self-hostable",
label: t`On-premise`,
free: false,
teams: false,
pro: false,
enterprise: true,
},
];
const securityFeatures: PlanFeature[] = [
{
key: "sso",
label: t`SSO`,
free: { text: t`Google SSO`, highlight: true },
teams: { text: t`Google SSO`, highlight: true },
pro: { text: t`Google SSO`, highlight: true },
enterprise: { text: t`Google SSO + SAML`, highlight: true },
},
{
key: "admin-roles",
label: t`Admin roles`,
free: { text: t`Admin roles`, highlight: true },
teams: { text: t`Admin roles`, highlight: true },
pro: { text: t`Admin roles`, highlight: true },
enterprise: { text: t`Advanced admin roles`, highlight: true },
},
];
const supportFeatures: PlanFeature[] = [
{
key: "priority-email-support",
label: t`Priority email support`,
free: false,
teams: true,
pro: true,
enterprise: true,
},
{
key: "account-manager",
label: t`Account manager`,
free: false,
teams: false,
pro: false,
enterprise: true,
},
{
key: "sla",
label: t`SLA`,
free: false,
teams: false,
pro: false,
enterprise: true,
},
];
const sections: FeatureSection[] = [
{
name: "",
features: planSpecificLimits,
},
{
name: t`Core features`,
features: coreFeatures,
},
{
name: t`Collaboration`,
features: collaborationFeatures,
},
{
name: t`Imports`,
features: importsFeatures,
}, },
{ {
name: t`Platform`, name: t`Platform`,
features: [ features: platformFeatures,
{ },
key: "api", {
label: t`REST API`, name: t`Security`,
kan: true, features: securityFeatures,
trello: true, },
}, {
{ name: t`Support`,
key: "open-source", features: supportFeatures,
label: t`Open source`,
kan: true,
trello: false,
},
{
key: "self-hostable",
label: t`Self-hostable`,
kan: true,
trello: false,
},
{
key: "white-label",
label: t`White label`,
kan: true,
trello: false,
},
{
key: "performance",
label: t`Fast & lightweight`,
kan: true,
trello: false,
},
{
key: "this-is-a-joke",
label: t`Owned by Atlassian`,
kan: false,
trello: true,
},
],
}, },
]; ];
const products = [ const plans = [
{ { id: "free", name: t`Free`, tierId: "tier-free" },
id: "kan", { id: "teams", name: t`Teams`, tierId: "tier-teams" },
name: t`Kan`, { id: "pro", name: t`Pro`, tierId: "tier-pro" },
featured: true, { id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" },
},
{
id: "trello",
name: t`Trello`,
featured: false,
},
]; ];
const CellValue = ({
value,
label,
}: {
value: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
label: string;
}) => {
if (typeof value === "object" && value !== null) {
const { text, highlight, highlightFirstWord } = value;
if (highlightFirstWord) {
const spaceIndex = text.indexOf(" ");
const firstWord = spaceIndex === -1 ? text : text.slice(0, spaceIndex);
const rest = spaceIndex === -1 ? "" : text.slice(spaceIndex);
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
<span className="text-sm font-medium">
<span className="text-light-1000 dark:text-dark-1000">{firstWord}</span>
<span className="text-light-950 dark:text-dark-800">{rest}</span>
</span>
</div>
);
}
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle
className={twMerge(
"mt-0.5 h-4 w-4 shrink-0",
highlight
? "text-light-1000 dark:text-dark-1000"
: "text-light-400 dark:text-dark-600",
)}
/>
<span
className={twMerge(
"text-sm font-medium",
highlight
? "text-light-1000 dark:text-dark-950"
: "text-light-950 dark:text-dark-800",
)}
>
{text}
</span>
</div>
);
}
if (value) {
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
<span className="text-sm font-medium text-light-1000 dark:text-dark-950">
{label}
</span>
</div>
);
}
return (
<div className="flex items-center gap-2.5">
<HiXCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-400 dark:text-dark-600" />
<span className="text-sm font-medium text-light-800 dark:text-dark-800">
{label}
</span>
</div>
);
};
const getFeatureValue = (
feature: PlanFeature,
planId: string,
): string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean } => {
switch (planId) {
case "free":
return feature.free;
case "teams":
return feature.teams;
case "pro":
return feature.pro;
case "enterprise":
return feature.enterprise;
default:
return false;
}
};
return ( return (
<section aria-labelledby="comparison-heading" className="w-full px-4"> <section aria-labelledby="comparison-heading" className="w-full">
<div className="mx-auto max-w-6xl py-8 lg:py-10"> <div className="mx-auto max-w-7xl">
<div className="grid grid-cols-3 gap-x-8 border-t border-light-500 before:block dark:border-dark-300"> <div
{products.map((product) => ( ref={containerRef}
<div key={product.id} aria-hidden="true" className="-mt-px"> className="relative overflow-x-auto rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50"
<div >
className={`${product.featured ? "border-dark-200 dark:border-dark-800" : "border-transparent"} border-t-2 pt-8`} {isHeaderFixed && (
> <div
<p className="hidden sm:block fixed z-40 border-b border-light-300 bg-light-50/95 dark:border-dark-400 dark:bg-dark-50/95"
className={`${product.featured ? "text-dark-50 dark:text-dark-1000" : "text-light-1000 dark:text-dark-1000"} text-center text-sm font-semibold`} style={{
> top: 64,
{product.name} left: headerRect.left,
</p> width: headerRect.width,
}}
>
<div className="grid grid-cols-4 border-x border-light-300 dark:border-dark-300">
{plans.map((plan) => {
const isMostPopular = plan.id === "pro";
return (
<div
key={`fixed-${plan.id}`}
className={twMerge(
"flex items-center px-6 py-3 text-left text-base font-semibold",
isMostPopular
? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
: "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
)}
>
{plan.name}
{plan.id === "pro" && <span className="ml-1 text-xl"></span>}
</div>
);
})}
</div> </div>
</div> </div>
))} )}
</div> <table className="w-full border-separate border-spacing-0">
<thead>
<div className="-mt-6 space-y-12"> <tr>
{sections.map((section) => ( {plans.map((plan) => {
<div key={section.name}> const isMostPopular = plan.id === "pro";
<h4 className="text-sm font-semibold text-light-1000 dark:text-dark-1000"> return (
{section.name} <th
</h4> key={plan.id}
<div className="relative -mx-8 mt-8"> scope="col"
<div className={twMerge(
aria-hidden="true" "w-1/4 px-6 py-4 text-left text-base font-semibold",
className="absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block" isMostPopular
> ? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
{products.map((product) => ( : "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
<div )}
key={product.id} >
className={`size-full rounded-lg ${product.featured ? "bg-white shadow-sm dark:bg-dark-50 dark:shadow-none" : ""}`} <span className="flex items-center">
/> {plan.name}
))} {plan.id === "pro" && <span className="ml-1 text-xl"></span>}
</div> </span>
</th>
<table className="relative w-full border-separate border-spacing-x-8"> );
<thead> })}
<tr className="text-left"> </tr>
<th scope="col"> </thead>
<span className="sr-only">{t`Feature`}</span> <tbody>
</th> {sections.map((section, sectionIdx) => (
{products.map((p) => ( <React.Fragment key={section.name || `section-${sectionIdx}`}>
<th key={p.id} scope="col"> {section.name && (
<span className="sr-only">{p.name}</span> <tr>
</th> {plans.map((plan) => {
))} const isPro = plan.id === "pro";
</tr> return (
</thead>
<tbody>
{section.features.map((feature, featureIdx) => (
<tr key={feature.key}>
<th
scope="row"
className="w-1/3 py-3 pr-4 text-left text-sm font-normal text-light-1000 dark:text-dark-1000"
>
{feature.label}
{featureIdx !== section.features.length - 1 ? (
<div className="absolute inset-x-8 mt-3 h-px bg-light-500 dark:bg-dark-300" />
) : null}
</th>
{products.map((p) => (
<td <td
key={p.id} key={plan.id}
className="relative w-1/3 px-4 py-0 text-center" className={twMerge(
"border-t px-6 py-3 text-left text-sm font-semibold text-dark-900 dark:text-dark-900",
isPro
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
: "border-light-300 bg-light-50 dark:border-dark-400 dark:bg-dark-50",
)}
> >
<span className="relative inline-flex w-full items-center justify-center py-3"> {plan.id === "free" ? section.name : ""}
<CellValue
value={
p.id === "kan" ? feature.kan : feature.trello
}
/>
</span>
</td> </td>
))} );
</tr> })}
))} </tr>
</tbody> )}
</table> {section.features.map((feature) => (
<tr key={feature.key}>
<div {plans.map((plan) => {
aria-hidden="true" const isPro = plan.id === "pro";
className="pointer-events-none absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block" return (
> <td
{products.map((product) => ( key={plan.id}
<div className={twMerge(
key={product.id} "w-1/4 border-t px-6 py-3 text-left",
className={`${product.featured ? "ring-1 ring-light-500 dark:ring-dark-800" : "ring-0"} rounded-2xl`} isPro
/> ? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
: "border-light-300 dark:border-dark-400",
)}
>
<CellValue
value={getFeatureValue(feature, plan.id)}
label={feature.label}
/>
</td>
);
})}
</tr>
))} ))}
</div> </React.Fragment>
</div> ))}
</div> </tbody>
))} </table>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -23,81 +23,115 @@ const Pricing = ({
}) => { }) => {
const tiers = [ const tiers = [
{ {
name: t`Individuals`, name: t`Free`,
id: "tier-individuals", id: "tier-free",
href: "signup", href: "signup",
buttonText: t`Get Started`, buttonText: t`Get started`,
price: { monthly: t`Free`, annually: t`Free` }, price: { monthly: t`$0`, annually: t`$0` },
description: t`Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.`, description: t`Free for everyone`,
featureHeader: t`Free, forever`, bestFor: t`Best for individuals and solo creators getting started`,
featureHeader: undefined,
features: [ features: [
t`1 user`, { text: t`1 user` },
t`Unlimited boards`, { text: t`Unlimited boards` },
t`Unlimited cards`, { text: t`Unlimited cards` },
t`Custom board templates`, { text: t`Custom board templates` },
t`Checklists`, { text: t`Checklists` },
], ],
showPrice: true, showPrice: true,
ctaSubtext: undefined,
mostPopular: false,
highlighted: false,
}, },
{ {
name: t`Teams`, name: t`Teams`,
id: "tier-teams", id: "tier-teams",
href: "signup", href: "signup",
buttonText: t`Get Started`, buttonText: t`Get started`,
price: { monthly: "$10.00", annually: "$8.00" }, price: { monthly: "$10", annually: "$8" },
description: t`Kan is better with a team. Perfect for small and growing teams looking to collaborate.`, description: t`Perfect for small and growing teams looking to collaborate.`,
featureHeader: t`Everything in the free plan, plus:`, bestFor: t`Best for small and growing teams that need collaboration`,
featureHeader: undefined,
features: [ features: [
t`Workspace members`, { text: t`All Free features +` },
t`Admin roles`, { text: t`Workspace members` },
t`Priority email support`, { text: t`Invite links` },
t`Support the development of the project`, { text: t`Unlimited file uploads` },
{ text: t`Email notifications for mentions` },
{ text: t`Priority email support` },
],
highlighted: false,
showPrice: true,
showPriceSuffix: true,
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
mostPopular: false,
},
{
name: t`Pro`,
id: "tier-pro",
href: "signup",
buttonText: t`Get started`,
price: { monthly: "$29", annually: "$24" },
description: t`Unlimited seats and advanced features for growing teams.`,
bestFor: t`Best for teams that want to scale without limits`,
featureHeader: undefined,
features: [
{ text: t`All Teams features +` },
{ text: t`Unlimited members` },
{ text: t`Custom workspace username` },
{ text: t`Configurable user roles and permissions` },
{ text: t`Custom branding` },
], ],
highlighted: true, highlighted: true,
showPrice: true, showPrice: true,
showPriceSuffix: true, showPriceSuffix: false,
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
mostPopular: true,
}, },
{ {
name: t`Self Host`, name: t`Enterprise`,
id: "tier-self-host", id: "tier-enterprise",
href: "https://github.com/kanbn/kan", href: "mailto:support@kan.bn?subject=Enterprise Inquiry",
buttonText: t`View docs`, buttonText: t`Contact Sales`,
price: { monthly: "-", annually: "-" }, price: { monthly: t`Contact us`, annually: t`Contact us` },
description: t`Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.`, description: t`Advanced security, compliance, and dedicated support for large organizations.`,
featureHeader: t`Complete control and ownership:`, bestFor: t`Best for large organizations with advanced needs`,
featureHeader: undefined,
features: [ features: [
t`Run on your own infrastructure`, { text: t`All Pro features +` },
t`Own your data`, { text: t`SAML SSO` },
t`Custom domain`, { text: t`Advanced admin controls` },
{ text: t`Dedicated account manager` },
{ text: t`Custom SLA` },
{ text: t`On-premise deployment option` },
], ],
highlighted: false,
showPrice: true,
showPriceSuffix: false,
ctaSubtext: undefined,
mostPopular: false, mostPopular: false,
showPrice: false,
}, },
]; ];
return ( return (
<> <>
<div className="flex flex-col items-center justify-center px-4 pb-10"> <div className="mx-auto max-w-7xl px-4">
<div className="mt-14 flex flex-col items-center justify-center"> <div className="mb-8 flex items-center justify-center">
<div className="mb-8 flex items-center gap-2 rounded-full border bg-white px-4 py-1.5 text-center text-xs font-bold text-gray-800 dark:border-dark-300 dark:bg-dark-1000 dark:text-gray-800 lg:text-sm">
<HiBolt />
<p>{t`Launch offer: unlimited seats for just $29/month with Pro`}</p>
</div>
<fieldset aria-label={t`Payment frequency`}> <fieldset aria-label={t`Payment frequency`}>
<RadioGroup <RadioGroup
value={frequency} value={frequency}
onChange={(value) => setFrequency(value)} onChange={(value) => setFrequency(value)}
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs/5 font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600" className="flex gap-1 rounded-lg border border-light-300 bg-light-50 p-1 dark:border-dark-300 dark:bg-dark-50"
> >
{frequencies.map((option) => ( {frequencies.map((option) => (
<Radio <Radio
key={option.value} key={option.value}
value={option} value={option}
className={twMerge( className={twMerge(
"cursor-pointer rounded-full px-2.5 py-1 text-xs transition-colors lg:text-sm", "cursor-pointer rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
frequency?.value === option.value frequency?.value === option.value
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50" ? "bg-white text-light-1000 shadow-sm dark:bg-dark-100 dark:text-dark-1000"
: "text-light-900 hover:bg-light-100 dark:hover:bg-dark-100", : "text-light-600 dark:text-dark-700",
)} )}
> >
{option.label} {option.label}
@@ -106,103 +140,129 @@ const Pricing = ({
</RadioGroup> </RadioGroup>
</fieldset> </fieldset>
</div> </div>
</div>
<div className="isolate mx-auto mb-10 grid max-w-md grid-cols-1 gap-8 px-4 lg:mx-0 lg:max-w-none lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{tiers.map((tier) => ( {tiers.map((tier) => (
<div <div
key={tier.id} key={tier.id}
className={twMerge( className={twMerge(
tier.highlighted "relative flex h-full flex-col rounded-lg border p-6",
? "bg-dark-50 ring-1 ring-dark-50 dark:ring-dark-800" tier.mostPopular
: "bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-300", ? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
"rounded-3xl p-8 xl:p-10", : "border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50",
)} )}
> >
<div className="flex items-center justify-between gap-x-4"> {tier.id === "tier-pro" && (
<h3 <div className="absolute right-2 top-2 z-10">
id={tier.id} <span className="inline-flex items-center gap-1 rounded-full border border-light-400 bg-light-100 px-3 py-1 text-center text-xs text-dark-600 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
className={twMerge( <HiBolt className="h-3 w-3 -ml-0.5" />
tier.highlighted {t`Launch offer`}
? "text-dark-1000 dark:text-dark-1000" </span>
: "text-dark-50 dark:text-dark-1000", </div>
"text-lg/8 font-semibold", )}
<h3
id={tier.id}
className={twMerge(
"mb-4 flex items-center text-base font-semibold",
tier.mostPopular
? "text-light-1000 dark:text-dark-1000"
: "text-light-1000 dark:text-dark-1000",
)}
>
{tier.name}
{tier.id === "tier-pro" && <span className="ml-1 text-xl leading-none"></span>}
</h3>
<div className="mb-4">
<p className="flex items-baseline gap-x-1">
<span
className={twMerge(
"text-2xl font-semibold",
tier.mostPopular
? "text-light-1000 dark:text-dark-1000"
: "text-light-1000 dark:text-dark-1000",
!tier.showPrice && "opacity-0",
)}
>
{tier.price[frequency?.value ?? "monthly"]}
</span>
{tier.id === "tier-pro" && (
<span
className={twMerge(
"text-2xl font-semibold line-through",
tier.mostPopular
? "text-light-600 dark:text-dark-600"
: "text-light-600 dark:text-dark-600",
)}
>
{frequency?.value === "annually" ? "$79" : "$100"}
</span>
)} )}
> {tier.showPriceSuffix && (
{tier.name} <span className="text-sm font-normal text-dark-400 dark:text-dark-700">
</h3> {frequency?.priceSuffix}
{tier.highlighted && ( </span>
<p className="rounded-full bg-light-50 px-2.5 py-1 text-[12px] font-semibold text-dark-500 dark:bg-dark-1000 dark:text-dark-50"> )}
{t`14 day free trial`} {tier.id === "tier-pro" && (
</p> <span className="text-sm font-normal text-dark-400 dark:text-dark-700">
{t`/month`}
</span>
)}
</p>
</div>
{tier.bestFor && (
<p className="mb-6 text-sm text-dark-400 dark:text-dark-700">
{tier.bestFor}
</p>
)}
<div className="mb-6">
{tier.id === "tier-enterprise" ? (
<a
href={tier.href}
aria-describedby={tier.id}
className="block w-full rounded-md border border-dark-200 bg-transparent px-4 py-2 text-center text-sm font-medium text-dark-50 transition-colors hover:bg-dark-50 hover:text-light-50 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-200"
>
{tier.buttonText}
</a>
) : (
<>
<Link
href={tier.href}
aria-describedby={tier.id}
className={twMerge(
"block w-full rounded-md px-4 py-2 text-center text-sm font-medium transition-colors",
tier.mostPopular
? "bg-dark-50 text-light-50 shadow-sm hover:bg-dark-100 dark:bg-dark-1000 dark:text-dark-50 dark:hover:bg-dark-900"
: "border border-dark-200 bg-transparent text-dark-50 transition-colors hover:bg-dark-50 hover:text-light-50 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-200",
)}
>
{tier.buttonText}
</Link>
</>
)} )}
</div> </div>
<p <ul role="list" className="space-y-2.5">
className={twMerge( {tier.features.map((feature, index) => (
"mt-4 text-sm/6 text-dark-950 dark:text-dark-900", <li
tier.highlighted ? "text-light-100" : "text-dark-50", key={`${tier.id}-feature-${index}`}
)} className="flex items-start gap-x-2.5"
> >
{tier.description} <HiCheckCircle
</p> className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000"
<p className="mt-6 flex items-baseline gap-x-1"> />
<span <span className="text-sm text-dark-600 dark:text-dark-800">
className={twMerge( {feature.text}
"text-3xl font-semibold tracking-tight text-light-100", </span>
tier.highlighted
? "text-light-50 dark:text-dark-1000"
: "text-gray-900 dark:text-dark-1000",
!tier.showPrice && "opacity-0",
)}
>
{tier.price[frequency?.value ?? "monthly"]}
</span>
{tier.showPriceSuffix && (
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">
{frequency?.priceSuffix}
</span>
)}
</p>
<Link
href={tier.href}
aria-describedby={tier.id}
className={twMerge(
tier.highlighted
? "bg-light-50 text-dark-50 shadow-sm dark:bg-dark-1000 dark:text-dark-50"
: "bg-dark-50 text-light-50 dark:bg-dark-200 dark:text-dark-1000",
"mt-6 block rounded-md px-3 py-2 text-center text-sm/6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600",
)}
>
{tier.buttonText}
</Link>
<p
className={twMerge(
"mt-8 text-sm/6 font-bold",
tier.highlighted
? "text-light-100 dark:text-dark-1000"
: "text-dark-50 dark:text-dark-1000",
)}
>
{tier.featureHeader}
</p>
<ul
role="list"
className={twMerge(
"mt-2 space-y-3 text-sm/6 text-light-600",
tier.highlighted
? "text-light-100 dark:text-dark-1000"
: "text-dark-50 dark:text-dark-1000",
)}
>
{tier.features.map((feature) => (
<li key={feature} className="flex items-center gap-x-3">
<HiCheckCircle className="h-5 w-5" />
{feature}
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
))} ))}
</div>
<div className="mx-auto mt-8 max-w-3xl px-4 text-center">
<div className="inline-flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<p>{t`14 day free trial · Switch plans or cancel anytime`}</p>
</div>
</div>
</div> </div>
</> </>
); );

View File

@@ -43,8 +43,8 @@ export default function PricingView() {
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl"> <p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
{t`Simple pricing`} {t`Simple pricing`}
</p> </p>
<p className="text:md lg:text-md mt-6 max-w-[500px] text-center text-light-950 dark:text-dark-900"> <p className="text:md lg:text-md mt-6 max-w-[300px] text-center text-light-950 dark:text-dark-900">
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`} {t`Start free. Upgrade as your team grows. No hidden fees, no contracts.`}
</p> </p>
</div> </div>
<PricingTiers <PricingTiers
@@ -59,23 +59,9 @@ export default function PricingView() {
</div> </div>
<div className="pb-22 flex flex-col items-center justify-center px-4"> <div className="pb-22 flex flex-col items-center justify-center px-4">
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
<p>{t`Features`}</p>
</div>
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
{t`Feature breakdown`}
</p>
<p className="text-md lg:text-md my-4 max-w-[500px] text-center text-light-950 dark:text-dark-900">
{t`Compare our features to see why Kan is the best choice.`}
</p>
<div className="mt-2">
<Button variant="primary" href="/signup">
{t`Get started`}
</Button>
</div>
<div className="mt-10 w-full max-w-[900px]"> <div className="mt-10 w-full">
<FeatureComparisonTable <FeatureComparisonTable
frequencyValue={frequency?.value ?? "annually"} frequencyValue={frequency?.value ?? "annually"}
/> />

View File

@@ -1,12 +1,13 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { HiXMark } from "react-icons/hi2"; import { HiLink, HiXMark } from "react-icons/hi2";
import Badge from "~/components/Badge"; import Badge from "~/components/Badge";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import LabelIcon from "~/components/LabelIcon"; import LabelIcon from "~/components/LabelIcon";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import ActivityList from "~/views/card/components/ActivityList"; import ActivityList from "~/views/card/components/ActivityList";
import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails"; import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails";
@@ -23,16 +24,35 @@ export function CardModal({
}) { }) {
const router = useRouter(); const router = useRouter();
const { closeModal, isOpen } = useModal(); const { closeModal, isOpen } = useModal();
const { showPopup } = usePopup();
const [showFade, setShowFade] = useState(false); const [showFade, setShowFade] = useState(false);
const [showTopFade, setShowTopFade] = useState(false); const [showTopFade, setShowTopFade] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const handleCopyCardLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
showPopup({
header: t`Link copied`,
icon: "success",
message: t`Card URL copied to clipboard`,
});
} catch (error) {
console.error(error);
showPopup({
header: t`Unable to copy link`,
icon: "error",
message: t`Please try again.`,
});
}
};
const { data, isLoading } = api.card.byId.useQuery( const { data, isLoading } = api.card.byId.useQuery(
{ {
cardPublicId: cardPublicId ?? "", cardPublicId: cardPublicId ?? "",
}, },
{ {
enabled: isOpen && !!cardPublicId, enabled: isOpen && !!cardPublicId && cardPublicId.length >= 12,
}, },
); );
@@ -65,33 +85,44 @@ export function CardModal({
<div className="h-full p-8"> <div className="h-full p-8">
<div className="mb-6"> <div className="mb-6">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<button <div className="absolute right-[2rem] top-[2rem] flex items-center gap-1">
className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300" <button
onClick={(e) => { type="button"
e.preventDefault(); onClick={handleCopyCardLink}
closeModal(); className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100"
aria-label="Copy card link"
>
<HiLink className="h-4 w-4 text-light-900 dark:text-dark-900" />
</button>
<button
type="button"
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100"
onClick={(e) => {
e.preventDefault();
closeModal();
setTimeout(() => { setTimeout(() => {
void router.replace( void router.replace(
{ {
pathname: router.pathname, pathname: router.pathname,
query: { query: {
...router.query, ...router.query,
workspaceSlug, workspaceSlug: workspaceSlug ?? "",
boardSlug: [boardSlug], boardSlug: [boardSlug ?? ""],
},
}, },
}, undefined,
undefined, { shallow: true },
{ shallow: true }, );
); }, 400);
}, 400); }}
}} >
> <HiXMark
<HiXMark size={18}
size={18} className="text-light-900 dark:text-dark-900"
className="dark:text-dark-9000 text-light-900" />
/> </button>
</button> </div>
{isLoading ? ( {isLoading ? (
<div className="flex space-x-2"> <div className="flex space-x-2">
<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" />

View File

@@ -70,30 +70,35 @@ export default function PublicBoardView() {
}, },
); );
const CopyBoardLink = () => { const handleCopyBoardLink = async () => {
return ( try {
<button await navigator.clipboard.writeText(window.location.href);
onClick={async () => { showPopup({
try { header: t`Link copied`,
await navigator.clipboard.writeText(window.location.href); icon: "success",
} catch (error) { message: t`Board URL copied to clipboard`,
console.error(error); });
} } catch (error) {
console.error(error);
showPopup({ showPopup({
header: t`Link copied`, header: t`Unable to copy link`,
icon: "success", icon: "error",
message: t`Board URL copied to clipboard`, message: t`Please try again.`,
}); });
}} }
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
aria-label={`Copy board URL`}
>
<HiLink className={`h-4 w-4 text-light-900 dark:text-dark-900`} />
</button>
);
}; };
const CopyBoardLink = () => (
<button
type="button"
onClick={handleCopyBoardLink}
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100"
aria-label="Copy board URL"
>
<HiLink className="h-4 w-4 text-light-900 dark:text-dark-900" />
</button>
);
const pathWithoutQuery = router.asPath.split("?")[0]; const pathWithoutQuery = router.asPath.split("?")[0];
const splitPath = pathWithoutQuery?.split("/") ?? []; const splitPath = pathWithoutQuery?.split("/") ?? [];
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null; const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;

View File

@@ -1,9 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2"; import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import { z } from "zod";
import Button from "~/components/Button"; import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
import Input from "~/components/Input";
import Modal from "~/components/modal"; import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
@@ -11,10 +15,28 @@ 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";
const githubTokenSchema = z.object({
token: z.string().min(1, { message: t`Token is required` }),
});
type GitHubTokenFormValues = z.infer<typeof githubTokenSchema>;
export default function IntegrationsSettings() { export default function IntegrationsSettings() {
const { modalContentType, isOpen } = useModal(); const { modalContentType, isOpen } = useModal();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const {
register,
handleSubmit,
formState: { isDirty, errors },
reset,
} = useForm<GitHubTokenFormValues>({
resolver: zodResolver(githubTokenSchema),
defaultValues: {
token: "",
},
});
const { const {
data: integrations, data: integrations,
refetch: refetchIntegrations, refetch: refetchIntegrations,
@@ -34,21 +56,25 @@ export default function IntegrationsSettings() {
}, },
); );
const { data: githubStatus, refetch: refetchGithubStatus } =
api.integration.getGitHubStatus.useQuery();
useEffect(() => { useEffect(() => {
const handleFocus = () => { const handleFocus = () => {
refetchIntegrations(); void refetchIntegrations();
void refetchGithubStatus();
}; };
window.addEventListener("focus", handleFocus); window.addEventListener("focus", handleFocus);
return () => { return () => {
window.removeEventListener("focus", handleFocus); window.removeEventListener("focus", handleFocus);
}; };
}, [refetchIntegrations]); }, [refetchIntegrations, refetchGithubStatus]);
const { mutateAsync: disconnectTrello } = const { mutateAsync: disconnectTrello } =
api.integration.disconnect.useMutation({ api.integration.disconnect.useMutation({
onSuccess: () => { onSuccess: () => {
refetchIntegrations(); void refetchIntegrations();
refetchTrelloUrl(); void refetchTrelloUrl();
showPopup({ showPopup({
header: t`Trello disconnected`, header: t`Trello disconnected`,
message: t`Your Trello account has been disconnected.`, message: t`Your Trello account has been disconnected.`,
@@ -64,6 +90,49 @@ export default function IntegrationsSettings() {
}, },
}); });
const { mutateAsync: saveGithubToken, isPending: isSavingGithubToken } =
api.integration.saveGitHubToken.useMutation({
onSuccess: () => {
void refetchGithubStatus();
reset();
showPopup({
header: t`GitHub connected`,
message: t`Your GitHub account has been connected.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Error connecting GitHub`,
message: t`An error occurred while connecting your GitHub account.`,
icon: "error",
});
},
});
const onSubmitGithubToken = (data: GitHubTokenFormValues) => {
void saveGithubToken({ token: data.token });
};
const { mutateAsync: disconnectGithub } =
api.integration.disconnectGitHub.useMutation({
onSuccess: () => {
void refetchGithubStatus();
showPopup({
header: t`GitHub disconnected`,
message: t`Your GitHub account has been disconnected.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Error disconnecting GitHub`,
message: t`An error occurred while disconnecting your GitHub account.`,
icon: "error",
});
},
});
return ( return (
<> <>
<PageHead title={t`Settings | Integrations`} /> <PageHead title={t`Settings | Integrations`} />
@@ -112,6 +181,51 @@ export default function IntegrationsSettings() {
)} )}
</div> </div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`GitHub`}
</h2>
{!githubStatus?.connected ? (
<>
<p className="mb-4 text-sm text-neutral-500 dark:text-dark-900">
{t`Connect your GitHub account to import projects.`}
</p>
<form
onSubmit={handleSubmit(onSubmitGithubToken)}
className="flex gap-2"
>
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
<Input
type="password"
placeholder="Personal Access Token"
{...register("token")}
errorMessage={errors.token?.message}
/>
</div>
<div>
<Button
variant="secondary"
type="submit"
disabled={!isDirty || isSavingGithubToken}
isLoading={isSavingGithubToken}
>
{t`Connect GitHub`}
</Button>
</div>
</form>
</>
) : (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Your GitHub account is connected.`}
</p>
<Button variant="secondary" onClick={() => disconnectGithub()}>
{t`Disconnect GitHub`}
</Button>
</>
)}
</div>
{/* Global modals */} {/* Global modals */}
<Modal <Modal
modalSize="md" modalSize="md"

View File

@@ -28,9 +28,11 @@ export default function PermissionsSettings() {
}); });
// Refresh any relevant workspace data // Refresh any relevant workspace data
await utils.workspace.byId.invalidate({ if (workspace.publicId && workspace.publicId.length >= 12) {
workspacePublicId: workspace.publicId, await utils.workspace.byId.invalidate({
}); workspacePublicId: workspace.publicId,
});
}
}, },
onError: () => { onError: () => {
showPopup({ showPopup({

View File

@@ -31,9 +31,10 @@ export default function WorkspaceSettings() {
const { data } = api.user.getUser.useQuery(); const { data } = api.user.getUser.useQuery();
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false); const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
const { data: workspaceData } = api.workspace.byId.useQuery({ const { data: workspaceData } = api.workspace.byId.useQuery(
workspacePublicId: workspace.publicId, { workspacePublicId: workspace.publicId },
}); { enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
);
const subscriptions = workspaceData?.subscriptions as const subscriptions = workspaceData?.subscriptions as
| Subscription[] | Subscription[]

View File

@@ -58,28 +58,6 @@ export default function Avatar({
const [crop, setCrop] = useState<PercentCrop>(); const [crop, setCrop] = useState<PercentCrop>();
const imgRef = useRef<HTMLImageElement | null>(null); const imgRef = useRef<HTMLImageElement | null>(null);
const updateUser = api.user.update.useMutation({
onSuccess: async () => {
showPopup({
header: t`Profile image updated`,
message: t`Your profile image has been updated.`,
icon: "success",
});
try {
await utils.user.getUser.refetch();
} catch (e) {
console.error(e);
throw e;
}
},
onError: () => {
showPopup({
header: t`Error updating profile image`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined; const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined;
@@ -187,29 +165,32 @@ export default function Avatar({
const originalExt = selectedFile.name.split(".").pop() ?? "jpg"; const originalExt = selectedFile.name.split(".").pop() ?? "jpg";
const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`; const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`;
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
const response = await fetch( const response = await fetch(
env("NEXT_PUBLIC_BASE_URL") + "/api/upload/image", `${baseUrl}/api/upload/avatar`,
{ {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": blob.type,
"x-original-filename": fileName,
}, },
body: JSON.stringify({ filename: fileName, contentType: blob.type }), body: blob,
}, },
); );
if (!response.ok) throw new Error("Failed to get pre-signed URL"); if (!response.ok) {
throw new Error("Failed to upload profile image");
}
const { url } = (await response.json()) as { url: string }; // User image is updated in the backend, refresh user data
await utils.user.getUser.refetch();
const uploadResponse = await fetch(url, { showPopup({
method: "PUT", header: t`Profile image updated`,
body: blob, message: t`Your profile image has been updated.`,
icon: "success",
}); });
if (!uploadResponse.ok) throw new Error("Failed to upload profile image");
updateUser.mutate({ image: fileName });
setCropDialogOpen(false); setCropDialogOpen(false);
resetCropState(); resetCropState();
} catch (error) { } catch (error) {
@@ -227,7 +208,7 @@ export default function Avatar({
resetCropState, resetCropState,
selectedFile, selectedFile,
showPopup, showPopup,
updateUser, utils.user.getUser,
userId, userId,
]); ]);

View File

@@ -22,9 +22,11 @@ export default function UpdateWorkspaceEmailVisibilityForm({
const updateWorkspace = api.workspace.update.useMutation({ const updateWorkspace = api.workspace.update.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.workspace.byId.invalidate({ if (workspacePublicId && workspacePublicId.length >= 12) {
workspacePublicId, void utils.workspace.byId.invalidate({
}); workspacePublicId,
});
}
}, },
}); });

View File

@@ -33,7 +33,7 @@ const UpdateWorkspaceUrlForm = ({
.min(3, { .min(3, {
message: t`URL must be at least 3 characters long`, message: t`URL must be at least 3 characters long`,
}) })
.max(24, { message: t`URL cannot exceed 24 characters` }) .max(64, { message: t`URL cannot exceed 64 characters` })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, { .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`, message: t`URL can only contain letters, numbers, and hyphens`,
}), }),

View File

@@ -1,4 +1,17 @@
services: services:
migrator:
image: ghcr.io/kanbn/kan-migrate:latest
container_name: ${MIGRATOR_CONTAINER_NAME:-kan-migrate}
build:
context: ..
dockerfile: apps/web/Dockerfile
target: migrate
networks:
- dokploy-network
environment:
- POSTGRES_URL=${POSTGRES_URL}
restart: "no"
web: web:
image: ghcr.io/kanbn/kan:latest image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web} container_name: ${CONTAINER_NAME:-kan-web}
@@ -9,6 +22,7 @@ services:
build: build:
context: .. context: ..
dockerfile: apps/web/Dockerfile dockerfile: apps/web/Dockerfile
target: web
args: args:
APP_VERSION: ${APP_VERSION:-} APP_VERSION: ${APP_VERSION:-}
env_file: env_file:
@@ -79,6 +93,9 @@ services:
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID} - NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY} - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST} - NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
depends_on:
migrator:
condition: service_completed_successfully
networks: networks:
dokploy-network: dokploy-network:

View File

@@ -1,4 +1,20 @@
services: services:
migrate:
image: ghcr.io/kanbn/kan-migrate:latest
container_name: ${CONTAINER_NAME:-kan-migrate}
networks:
- kan-network
build:
context: .
dockerfile: ./apps/web/Dockerfile
target: migrate
environment:
- POSTGRES_URL=${POSTGRES_URL}
depends_on:
postgres:
condition: service_healthy
restart: "no"
web: web:
image: ghcr.io/kanbn/kan:latest image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web} container_name: ${CONTAINER_NAME:-kan-web}
@@ -6,10 +22,10 @@ services:
- "${WEB_PORT:-3000}:3000" - "${WEB_PORT:-3000}:3000"
networks: networks:
- kan-network - kan-network
- dokploy-network
build: build:
context: . context: .
dockerfile: ./apps/web/Dockerfile.new dockerfile: ./apps/web/Dockerfile
target: web
env_file: env_file:
- .env - .env
environment: environment:
@@ -107,7 +123,8 @@ services:
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET} - APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER} - APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
depends_on: depends_on:
- postgres migrate:
condition: service_completed_successfully
restart: unless-stopped restart: unless-stopped
postgres: postgres:
@@ -121,14 +138,17 @@ services:
- 5432:5432 - 5432:5432
volumes: volumes:
- kan_postgres_data:/var/lib/postgresql/data - kan_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped restart: unless-stopped
networks: networks:
- kan-network - kan-network
networks: networks:
kan-network: kan-network:
dokploy-network:
external: true
volumes: volumes:
kan_postgres_data: kan_postgres_data:

View File

@@ -27,6 +27,10 @@
"./utils/rateLimit": { "./utils/rateLimit": {
"types": "./dist/utils/rateLimit.d.ts", "types": "./dist/utils/rateLimit.d.ts",
"default": "./src/utils/rateLimit.ts" "default": "./src/utils/rateLimit.ts"
},
"./utils/permissions": {
"types": "./dist/utils/permissions.d.ts",
"default": "./src/utils/permissions.ts"
} }
}, },
"license": "GPL-3.0", "license": "GPL-3.0",
@@ -39,8 +43,6 @@
"typecheck": "tsc --noEmit --emitDeclarationOnly false" "typecheck": "tsc --noEmit --emitDeclarationOnly false"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0",
"@kan/auth": "workspace:*", "@kan/auth": "workspace:*",
"@kan/db": "workspace:*", "@kan/db": "workspace:*",
"@kan/email": "workspace:^", "@kan/email": "workspace:^",

View File

@@ -9,7 +9,7 @@ import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions"; import { assertPermission } from "../utils/permissions";
import { deleteObject, generateUploadUrl } from "../utils/s3"; import { deleteObject, generateUploadUrl } from "@kan/shared/utils";
export const attachmentRouter = createTRPCRouter({ export const attachmentRouter = createTRPCRouter({
generateUploadUrl: protectedProcedure generateUploadUrl: protectedProcedure
@@ -142,9 +142,18 @@ export const attachmentRouter = createTRPCRouter({
createdBy: userId, createdBy: userId,
}); });
if (!attachment) {
throw new TRPCError({
message: "Failed to create attachment",
code: "INTERNAL_SERVER_ERROR",
});
}
await cardActivityRepo.create(ctx.db, { await cardActivityRepo.create(ctx.db, {
type: "card.updated.attachment.added", type: "card.updated.attachment.added",
cardId: card.id, cardId: card.id,
attachmentId: attachment.id,
toTitle: input.originalFilename,
createdBy: userId, createdBy: userId,
}); });
@@ -206,6 +215,8 @@ export const attachmentRouter = createTRPCRouter({
await cardActivityRepo.create(ctx.db, { await cardActivityRepo.create(ctx.db, {
type: "card.updated.attachment.removed", type: "card.updated.attachment.removed",
cardId: attachment.cardId, cardId: attachment.cardId,
attachmentId: attachment.id,
fromTitle: attachment.originalFilename,
createdBy: userId, createdBy: userId,
}); });

View File

@@ -10,6 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { colours } from "@kan/shared/constants"; import { colours } from "@kan/shared/constants";
import { import {
convertDueDateFiltersToRanges, convertDueDateFiltersToRanges,
generateAvatarUrl,
generateSlug, generateSlug,
generateUID, generateUID,
} from "@kan/shared/utils"; } from "@kan/shared/utils";
@@ -33,6 +34,7 @@ export const boardRouter = createTRPCRouter({
z.object({ z.object({
workspacePublicId: z.string().min(12), workspacePublicId: z.string().min(12),
type: z.enum(["regular", "template"]).optional(), type: z.enum(["regular", "template"]).optional(),
archived: z.boolean().optional(),
}), }),
) )
.output( .output(
@@ -64,7 +66,10 @@ export const boardRouter = createTRPCRouter({
ctx.db, ctx.db,
workspace.id, workspace.id,
userId, userId,
{ type: input.type } {
type: input.type,
archived: input.archived ?? false,
}
); );
return result; return result;
@@ -142,7 +147,63 @@ export const boardRouter = createTRPCRouter({
}, },
); );
return result; if (!result) {
throw new TRPCError({
message: `Board with public ID ${input.boardPublicId} not found`,
code: "NOT_FOUND",
});
}
// Generate presigned URLs for workspace member avatars
const workspaceWithAvatarUrls = result.workspace
? {
...result.workspace,
members: await Promise.all(
result.workspace.members.map(async (member) => {
if (!member.user?.image) {
return member;
}
const avatarUrl = await generateAvatarUrl(member.user.image);
return {
...member,
user: {
...member.user,
image: avatarUrl,
},
};
}),
),
}
: result.workspace;
// Generate presigned URLs for card member avatars
const listsWithAvatarUrls = await Promise.all(
result.lists.map(async (list) => ({
...list,
cards: await Promise.all(
list.cards.map(async (card) => ({
...card,
members: await Promise.all(
card.members.map(async (member) => {
if (!member.user?.image) return member;
const avatarUrl = await generateAvatarUrl(member.user.image);
return {
...member,
user: { ...member.user, image: avatarUrl },
};
}),
),
})),
),
})),
);
return {
...result,
lists: listsWithAvatarUrls,
workspace: workspaceWithAvatarUrls,
};
}), }),
bySlug: publicProcedure bySlug: publicProcedure
.meta({ .meta({
@@ -161,7 +222,7 @@ export const boardRouter = createTRPCRouter({
workspaceSlug: z workspaceSlug: z
.string() .string()
.min(3) .min(3)
.max(24) .max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/), .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
boardSlug: z boardSlug: z
.string() .string()
@@ -404,7 +465,8 @@ export const boardRouter = createTRPCRouter({
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/) .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(), .optional(),
visibility: z.enum(["public", "private"]).optional(), visibility: z.enum(["public", "private"]).optional(),
favorite: z.boolean().optional() favorite: z.boolean().optional(),
isArchived: z.boolean().optional(),
}), }),
) )
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>())) .output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
@@ -446,7 +508,7 @@ export const boardRouter = createTRPCRouter({
} }
// Handle other updates (name, slug, visibility) // Handle other updates (name, slug, visibility)
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined; const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined || input.isArchived !== undefined;
if (!hasOtherUpdates) { if (!hasOtherUpdates) {
// Only favorite was updated, return success // Only favorite was updated, return success
@@ -473,6 +535,7 @@ export const boardRouter = createTRPCRouter({
slug: input.slug, slug: input.slug,
boardPublicId: input.boardPublicId, boardPublicId: input.boardPublicId,
visibility: input.visibility, visibility: input.visibility,
isArchived: input.isArchived,
}); });
if (!result) if (!result)

View File

@@ -10,8 +10,13 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { mergeActivities } from "../utils/activities"; import { mergeActivities } from "../utils/activities";
import { sendMentionEmails } from "../utils/notifications";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions"; import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
import { generateDownloadUrl } from "../utils/s3"; import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
import {
createCardWebhookPayload,
sendWebhooksForWorkspace,
} from "../utils/webhook";
export const cardRouter = createTRPCRouter({ export const cardRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -153,6 +158,43 @@ export const cardRouter = createTRPCRouter({
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert); await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
} }
if (input.description) {
sendMentionEmails({
db: ctx.db,
cardPublicId: newCard.publicId,
commentHtml: input.description,
commenterUserId: userId,
}).catch((error) => {
console.error("Failed to send mention emails:", error);
});
}
// Fire webhooks (non-blocking)
sendWebhooksForWorkspace(
ctx.db,
list.workspaceId,
createCardWebhookPayload(
"card.created",
{
id: String(newCard.id),
title: input.title,
description: input.description,
dueDate: input.dueDate ?? null,
listId: String(newCard.listId),
},
{
boardId: list.boardPublicId,
boardName: list.boardName,
listName: list.name,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,
},
),
).catch((error) => {
console.error("Webhook delivery failed:", error);
});
return newCard; return newCard;
}), }),
addComment: protectedProcedure addComment: protectedProcedure
@@ -215,6 +257,16 @@ export const cardRouter = createTRPCRouter({
createdBy: userId, createdBy: userId,
}); });
sendMentionEmails({
db: ctx.db,
cardPublicId: input.cardPublicId,
commentHtml: input.comment,
commenterUserId: userId,
commentId: newComment.id,
}).catch((error) => {
console.error("Failed to send mention emails:", error);
});
return newComment; return newComment;
}), }),
updateComment: protectedProcedure updateComment: protectedProcedure
@@ -295,6 +347,16 @@ export const cardRouter = createTRPCRouter({
createdBy: userId, createdBy: userId,
}); });
sendMentionEmails({
db: ctx.db,
cardPublicId: input.cardPublicId,
commentHtml: input.comment,
commenterUserId: userId,
commentId: updatedComment.id,
}).catch((error) => {
console.error("Failed to send mention emails:", error);
});
return updatedComment; return updatedComment;
}), }),
deleteComment: protectedProcedure deleteComment: protectedProcedure
@@ -631,45 +693,54 @@ export const cardRouter = createTRPCRouter({
}); });
// Generate URLs for all attachments // Generate URLs for all attachments
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME; const attachmentsWithUrls = await Promise.all(
if (result.attachments && Array.isArray(result.attachments)) { result.attachments.map(async (attachment) => {
const attachments = result.attachments as { const url = await generateAttachmentUrl(attachment.s3Key);
publicId: string; return {
contentType: string; publicId: attachment.publicId,
s3Key: string; contentType: attachment.contentType,
originalFilename: string | null; s3Key: attachment.s3Key,
size?: number | null; originalFilename: attachment.originalFilename,
}[]; size: attachment.size,
url,
};
}),
);
const attachmentsWithUrls = await Promise.all( // Generate presigned URLs for workspace member avatars
attachments.map(async (attachment) => { const workspaceWithAvatarUrls = result.list.board.workspace
const base = { ? {
publicId: attachment.publicId, ...result.list.board.workspace,
contentType: attachment.contentType, members: await Promise.all(
s3Key: attachment.s3Key, result.list.board.workspace.members.map(async (member) => {
originalFilename: attachment.originalFilename, if (!member.user?.image) {
size: attachment.size, return member;
}; }
if (!bucket || !attachment.s3Key) {
return { ...base, url: null };
}
try {
const url = await generateDownloadUrl(
bucket,
attachment.s3Key,
86400, // 24 hours expiration
);
return { ...base, url };
} catch {
// If URL generation fails, return attachment with url: null
return { ...base, url: null };
}
}),
);
return { ...result, attachments: attachmentsWithUrls };
}
return { ...result, attachments: [] }; const avatarUrl = await generateAvatarUrl(member.user.image);
return {
...member,
user: {
...member.user,
image: avatarUrl,
},
};
}),
),
}
: result.list.board.workspace;
return {
...result,
attachments: attachmentsWithUrls,
list: {
...result.list,
board: {
...result.list.board,
workspace: workspaceWithAvatarUrls,
},
},
};
}), }),
getActivities: publicProcedure getActivities: publicProcedure
.meta({ .meta({
@@ -738,7 +809,39 @@ export const cardRouter = createTRPCRouter({
}, },
); );
const mergedActivities = mergeActivities(result.activities); // Generate presigned URLs for user avatars in activities
const activitiesWithAvatarUrls = await Promise.all(
result.activities.map(async (activity) => {
const updatedActivity = { ...activity };
// Generate presigned URL for activity user avatar
if (activity.user?.image) {
const userAvatarUrl = await generateAvatarUrl(activity.user.image);
updatedActivity.user = {
...activity.user,
image: userAvatarUrl,
};
}
// Generate presigned URL for member user avatar (if exists)
if (activity.member?.user?.image) {
const memberAvatarUrl = await generateAvatarUrl(
activity.member.user.image,
);
updatedActivity.member = {
...activity.member,
user: {
...activity.member.user,
image: memberAvatarUrl,
},
};
}
return updatedActivity;
}),
);
const mergedActivities = mergeActivities(activitiesWithAvatarUrls);
return { return {
activities: mergedActivities, activities: mergedActivities,
@@ -883,6 +986,15 @@ export const cardRouter = createTRPCRouter({
fromDescription: existingCard.description ?? undefined, fromDescription: existingCard.description ?? undefined,
toDescription: input.description, toDescription: input.description,
}); });
sendMentionEmails({
db: ctx.db,
cardPublicId: input.cardPublicId,
commentHtml: input.description,
commenterUserId: userId,
}).catch((error) => {
console.error("Failed to send mention emails:", error);
});
} }
if ( if (
@@ -925,6 +1037,59 @@ export const cardRouter = createTRPCRouter({
await cardActivityRepo.bulkCreate(ctx.db, activities); await cardActivityRepo.bulkCreate(ctx.db, activities);
} }
// Build changes object for webhook
const webhookChanges: Record<string, { from: unknown; to: unknown }> = {};
if (input.title && existingCard.title !== input.title) {
webhookChanges.title = { from: existingCard.title, to: input.title };
}
if (input.description && existingCard.description !== input.description) {
webhookChanges.description = {
from: existingCard.description,
to: input.description,
};
}
if (
input.dueDate !== undefined &&
previousDueDate?.getTime() !== input.dueDate?.getTime()
) {
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
}
if (newListId && existingCard.listId !== newListId) {
webhookChanges.listId = { from: existingCard.listId, to: newListId };
}
// Fire webhooks (non-blocking)
sendWebhooksForWorkspace(
ctx.db,
card.workspaceId,
createCardWebhookPayload(
newListId && existingCard.listId !== newListId
? "card.moved"
: "card.updated",
{
id: String(result.id),
title: result.title,
description: result.description,
dueDate: result.dueDate,
listId: String(newListId ?? existingCard.listId),
},
{
boardId: card.boardPublicId,
boardName: card.boardName,
listName: card.listName,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,
changes:
Object.keys(webhookChanges).length > 0
? webhookChanges
: undefined,
},
),
).catch((error) => {
console.error("Webhook delivery failed:", error);
});
return result; return result;
}), }),
delete: protectedProcedure delete: protectedProcedure
@@ -972,6 +1137,9 @@ export const cardRouter = createTRPCRouter({
card.createdBy, card.createdBy,
); );
// Fetch full card data before delete for webhook
const fullCard = await cardRepo.getByPublicId(ctx.db, input.cardPublicId);
const deletedAt = new Date(); const deletedAt = new Date();
await cardRepo.softDelete(ctx.db, { await cardRepo.softDelete(ctx.db, {
@@ -986,6 +1154,34 @@ export const cardRouter = createTRPCRouter({
createdBy: userId, createdBy: userId,
}); });
// Fire webhooks (non-blocking)
if (fullCard) {
sendWebhooksForWorkspace(
ctx.db,
card.workspaceId,
createCardWebhookPayload(
"card.deleted",
{
id: String(fullCard.id),
title: fullCard.title,
description: fullCard.description,
dueDate: fullCard.dueDate,
listId: String(fullCard.listId),
},
{
boardId: card.boardPublicId,
boardName: card.boardName,
listName: card.listName,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,
},
),
).catch((error) => {
console.error("Webhook delivery failed:", error);
});
}
return { success: true }; return { success: true };
}), }),
}); });

View File

@@ -81,6 +81,16 @@ export const checklistRouter = createTRPCRouter({
return newChecklist; return newChecklist;
}), }),
update: protectedProcedure update: protectedProcedure
.meta({
openapi: {
summary: "Update a checklist",
method: "PUT",
path: "/checklists/{checklistPublicId}",
description: "Updates a checklist by its public ID",
tags: ["Cards"],
protect: true,
},
})
.input( .input(
z.object({ z.object({
checklistPublicId: z.string().length(12), checklistPublicId: z.string().length(12),

View File

@@ -24,7 +24,7 @@ import {
createTRPCRouter, createTRPCRouter,
publicProcedure, publicProcedure,
} from "../trpc"; } from "../trpc";
import { createS3Client } from "../utils/s3"; import { createS3Client } from "@kan/shared/utils";
const checkDatabaseConnection = async (db: dbClient) => { const checkDatabaseConnection = async (db: dbClient) => {
try { try {

View File

@@ -11,10 +11,12 @@ import * as labelRepo from "@kan/db/repository/label.repo";
import * as listRepo from "@kan/db/repository/list.repo"; import * as listRepo from "@kan/db/repository/list.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { colours } from "@kan/shared/constants"; import { colours } from "@kan/shared/constants";
import { generateUID } from "@kan/shared/utils"; import { generateSlug, generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions"; import { assertPermission } from "../utils/permissions";
import { assertUserInWorkspace } from "../utils/auth";
import { decryptToken } from "../utils/encryption";
import { apiKeys, urls } from "./integration"; import { apiKeys, urls } from "./integration";
export interface TrelloBoard { export interface TrelloBoard {
@@ -50,6 +52,51 @@ interface TrelloCheckItem {
pos: number; pos: number;
} }
interface GitHubProjectsResponse {
data: {
viewer: {
projectsV2: {
nodes?: { id: string; title: string }[];
};
organizations: {
nodes: {
projectsV2: {
nodes?: { id: string; title: string }[];
};
}[];
};
};
};
errors?: unknown[];
}
interface GitHubGraphQLResponse {
data?: {
node?: GitHubProjectV2Node;
};
errors?: unknown[];
}
interface GitHubProjectV2Node {
title: string;
field?: {
options?: { id: string; name: string }[];
};
areaField?: {
options?: { id: string; name: string; color: string }[];
};
items?: {
nodes: {
fieldValueByName?: { name: string };
areaValue?: { name: string };
content?: {
title?: string;
body?: string;
};
}[];
};
}
interface TrelloCard { interface TrelloCard {
id: string; id: string;
name: string | null; name: string | null;
@@ -464,4 +511,436 @@ export const importRouter = createTRPCRouter({
return { boardsCreated }; return { boardsCreated };
}), }),
}), }),
github: createTRPCRouter({
getProjects: protectedProcedure
.meta({
openapi: {
summary: "Get projects from GitHub",
method: "GET",
path: "/integrations/github/projects",
description: "Retrieves all projects from GitHub",
tags: ["Integrations"],
protect: true,
},
})
.input(z.void())
.output(z.array(z.object({ id: z.string(), name: z.string() })))
.query(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const integration = await integrationsRepo.getProviderForUser(
ctx.db,
user.id,
"github",
);
if (!integration)
throw new TRPCError({
message: "GitHub token not found",
code: "UNAUTHORIZED",
});
const token = decryptToken(integration.accessToken);
// GraphQL query to fetch Projects V2 for the user and their organizations
const query = `
query {
viewer {
projectsV2(first: 20) {
nodes {
id
title
}
}
organizations(first: 10) {
nodes {
projectsV2(first: 10) {
nodes {
id
title
}
}
}
}
}
}
`;
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `token ${token}`,
"Content-Type": "application/json",
"User-Agent": "Kan-App",
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
const errorText = await response.text();
console.error(
`GitHub API Error: ${response.status} ${response.statusText}`,
);
console.error(`GitHub API Response: ${errorText}`);
throw new TRPCError({
message: `Failed to fetch GitHub projects: ${response.status} ${response.statusText}`,
code: "INTERNAL_SERVER_ERROR",
});
}
const result = (await response.json()) as GitHubProjectsResponse;
if (result.errors) {
console.error("GitHub GraphQL Errors:", result.errors);
throw new TRPCError({
message: "Failed to fetch GitHub projects (GraphQL Error)",
code: "INTERNAL_SERVER_ERROR",
});
}
const userProjects = result.data.viewer.projectsV2.nodes ?? [];
const orgProjects = result.data.viewer.organizations.nodes.flatMap(
(org) => org.projectsV2.nodes ?? [],
);
const allProjects = [...userProjects, ...orgProjects];
return allProjects.map((project) => ({
id: project.id,
name: project.title,
}));
}),
importProjects: protectedProcedure
.meta({
openapi: {
summary: "Import projects from GitHub",
method: "POST",
path: "/imports/github/projects",
description: "Imports projects from GitHub",
tags: ["Imports"],
protect: true,
},
})
.input(
z.object({
projectIds: z.array(z.string()),
workspacePublicId: z.string().min(12),
}),
)
.output(z.object({ projectsImported: z.number() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) throw new TRPCError({ code: "UNAUTHORIZED" });
const integration = await integrationsRepo.getProviderForUser(
ctx.db,
userId,
"github",
);
if (!integration)
throw new TRPCError({
code: "UNAUTHORIZED",
message: "GitHub token not found",
});
const token = decryptToken(integration.accessToken);
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
code: "NOT_FOUND",
message: "Workspace not found",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
const newImport = await importRepo.create(ctx.db, {
source: "github",
createdBy: userId,
});
if (!newImport) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create import record",
});
}
const newImportId = newImport.id;
let projectsImported = 0;
for (const projectId of input.projectIds) {
// GraphQL query to fetch Project V2 details, status options, area options, and items
const query = `
query($id: ID!) {
node(id: $id) {
... on ProjectV2 {
title
field(name: "Status") {
... on ProjectV2SingleSelectField {
options {
id
name
}
}
}
areaField: field(name: "Area") {
... on ProjectV2SingleSelectField {
options {
id
name
color
}
}
}
items(first: 100) {
nodes {
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue {
name
}
}
areaValue: fieldValueByName(name: "Area") {
... on ProjectV2ItemFieldSingleSelectValue {
name
}
}
content {
... on Issue {
title
body
}
... on PullRequest {
title
body
}
... on DraftIssue {
title
body
}
}
}
}
}
}
}
`;
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `token ${token}`,
"Content-Type": "application/json",
"User-Agent": "Kan-App",
},
body: JSON.stringify({ query, variables: { id: projectId } }),
});
const result = (await response.json()) as GitHubGraphQLResponse;
if (result.errors || !result.data?.node) continue;
const projectData = result.data.node;
const statusOptions = projectData.field?.options ?? [];
const areaOptions = projectData.areaField?.options ?? [];
const items = projectData.items?.nodes ?? [];
const boardPublicId = generateUID();
const board = await boardRepo.create(ctx.db, {
publicId: boardPublicId,
name: projectData.title,
workspaceId: workspace.id,
slug: generateSlug(projectData.title),
createdBy: userId,
importId: newImportId,
});
if (!board) continue;
// Prepare Labels
const labelsInsert = areaOptions.map((option) => {
let colourCode = "#0d9488"; // Default Teal
const ghColor = option.color;
// Map GitHub colors to Kan colors
if (ghColor === "BLUE") colourCode = "#0284c7";
else if (ghColor === "GREEN") colourCode = "#65a30d";
else if (ghColor === "YELLOW") colourCode = "#ca8a04";
else if (ghColor === "ORANGE") colourCode = "#ea580c";
else if (ghColor === "RED") colourCode = "#dc2626";
else if (ghColor === "PINK") colourCode = "#db2777";
else if (ghColor === "PURPLE") colourCode = "#4f46e5";
else if (ghColor === "GRAY") colourCode = "#0d9488";
return {
publicId: generateUID(),
name: option.name,
colourCode,
createdBy: userId,
boardId: board.id,
importId: newImportId,
};
});
const createdLabels = await labelRepo.bulkCreate(
ctx.db,
labelsInsert,
);
const labelMap = new Map<string, number>();
createdLabels.forEach((label, index) => {
const originalName = areaOptions[index]?.name;
if (originalName) {
labelMap.set(originalName, label.id);
}
});
// Prepare Lists
const listsInsert: {
publicId: string;
name: string;
createdBy: string;
boardId: number;
index: number;
importId: number;
}[] = [];
if (statusOptions.length === 0) {
listsInsert.push({
publicId: generateUID(),
name: "To Do",
createdBy: userId,
boardId: board.id,
index: 0,
importId: newImportId,
});
} else {
statusOptions.forEach((option, index) => {
listsInsert.push({
publicId: generateUID(),
name: option.name,
createdBy: userId,
boardId: board.id,
index: index,
importId: newImportId,
});
});
}
const createdLists = await listRepo.bulkCreate(ctx.db, listsInsert);
const listIdMap = new Map<string, number>();
createdLists.forEach((list, index) => {
const originalName = listsInsert[index]?.name;
if (originalName) {
listIdMap.set(originalName, list.id);
}
});
// Prepare Cards
const itemsToInsert: {
item: NonNullable<
NonNullable<
NonNullable<GitHubProjectV2Node["items"]>["nodes"]
>[number]
>;
listId: number;
title: string;
description: string;
}[] = [];
for (const item of items) {
const statusName = item.fieldValueByName?.name;
const content = item.content ?? {};
const title = content.title ?? "Untitled Card";
const description = content.body ?? "";
let listId = statusName ? listIdMap.get(statusName) : undefined;
// Fallback to first list
if (!listId && createdLists.length > 0) {
listId = createdLists[0]?.id;
}
if (listId) {
itemsToInsert.push({
item,
listId,
title,
description,
});
}
}
const cardsInput = itemsToInsert.map((data, index) => ({
publicId: generateUID(),
title: data.title,
description: data.description,
createdBy: userId,
listId: data.listId,
index: index,
importId: newImportId,
}));
const createdCards = await cardRepo.bulkCreate(ctx.db, cardsInput);
// Create Activities
const activities = createdCards.map((card) => ({
type: "card.created" as const,
cardId: card.id,
createdBy: userId,
}));
if (activities.length > 0) {
await cardActivityRepo.bulkCreate(ctx.db, activities);
}
// Link Labels
const cardLabelRelations: { cardId: number; labelId: number }[] = [];
createdCards.forEach((card, index) => {
const originalItem = itemsToInsert[index]?.item;
const areaName = originalItem?.areaValue?.name;
if (areaName) {
const labelId = labelMap.get(areaName);
if (labelId) {
cardLabelRelations.push({
cardId: card.id,
labelId: labelId,
});
}
}
});
if (cardLabelRelations.length > 0) {
await cardRepo.bulkCreateCardLabelRelationships(
ctx.db,
cardLabelRelations,
);
}
projectsImported++;
}
if (projectsImported > 0 && newImportId) {
await importRepo.update(
ctx.db,
{ status: "success" },
{ importId: newImportId },
);
}
return { projectsImported };
}),
}),
}); });

View File

@@ -14,7 +14,65 @@ export const apiKeys = {
trello: process.env.TRELLO_APP_API_KEY, trello: process.env.TRELLO_APP_API_KEY,
}; };
import { encryptToken } from "../utils/encryption";
export const integrationRouter = createTRPCRouter({ export const integrationRouter = createTRPCRouter({
saveGitHubToken: protectedProcedure
.input(z.object({ token: z.string() }))
.mutation(async ({ ctx, input }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const encryptedToken = encryptToken(input.token);
const expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1);
await integrationsRepo.createOrUpdateProvider(ctx.db, {
provider: "github",
userId: user.id,
accessToken: encryptedToken,
expiresAt,
});
return { success: true };
}),
disconnectGitHub: protectedProcedure.mutation(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
await integrationsRepo.deleteProviderForUser(ctx.db, user.id, "github");
return { success: true };
}),
getGitHubStatus: protectedProcedure.query(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const connected = await integrationsRepo.isProviderAvailableForUser(
ctx.db,
user.id,
"github",
);
return { connected };
}),
providers: protectedProcedure providers: protectedProcedure
.meta({ .meta({
openapi: { openapi: {
@@ -67,7 +125,7 @@ export const integrationRouter = createTRPCRouter({
protect: true, protect: true,
}, },
}) })
.input(z.object({ provider: z.enum(["trello"]) })) .input(z.object({ provider: z.enum(["trello", "github"]) }))
.output(z.object({})) .output(z.object({}))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const user = ctx.user; const user = ctx.user;

View File

@@ -210,11 +210,12 @@ export const memberRouter = createTRPCRouter({
input.memberPublicId, input.memberPublicId,
); );
if (!member) if (!member || member.workspaceId !== workspace.id) {
throw new TRPCError({ throw new TRPCError({
message: `Member with public ID ${input.memberPublicId} not found`, message: `Member with public ID ${input.memberPublicId} not found`,
code: "NOT_FOUND", code: "NOT_FOUND",
}); });
}
const deletedMember = await memberRepo.softDelete(ctx.db, { const deletedMember = await memberRepo.softDelete(ctx.db, {
memberId: member.id, memberId: member.id,
@@ -720,7 +721,7 @@ export const memberRouter = createTRPCRouter({
input.memberPublicId, input.memberPublicId,
); );
if (!member) { if (!member || member.workspaceId !== workspace.id) {
throw new TRPCError({ throw new TRPCError({
message: "Member not found", message: "Member not found",
code: "NOT_FOUND", code: "NOT_FOUND",

View File

@@ -133,7 +133,7 @@ export const permissionRouter = createTRPCRouter({
input.memberPublicId, input.memberPublicId,
); );
if (!member) { if (!member || member.workspaceId !== workspace.id) {
throw new TRPCError({ throw new TRPCError({
message: "Member not found", message: "Member not found",
code: "NOT_FOUND", code: "NOT_FOUND",
@@ -209,7 +209,7 @@ export const permissionRouter = createTRPCRouter({
input.memberPublicId, input.memberPublicId,
); );
if (!member) { if (!member || member.workspaceId !== workspace.id) {
throw new TRPCError({ throw new TRPCError({
message: "Member not found", message: "Member not found",
code: "NOT_FOUND", code: "NOT_FOUND",
@@ -274,7 +274,7 @@ export const permissionRouter = createTRPCRouter({
input.memberPublicId, input.memberPublicId,
); );
if (!member) { if (!member || member.workspaceId !== workspace.id) {
throw new TRPCError({ throw new TRPCError({
message: "Member not found", message: "Member not found",
code: "NOT_FOUND", code: "NOT_FOUND",
@@ -339,7 +339,7 @@ export const permissionRouter = createTRPCRouter({
input.memberPublicId, input.memberPublicId,
); );
if (!member) { if (!member || member.workspaceId !== workspace.id) {
throw new TRPCError({ throw new TRPCError({
message: "Member not found", message: "Member not found",
code: "NOT_FOUND", code: "NOT_FOUND",

View File

@@ -4,6 +4,7 @@ import { z } from "zod";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { generateAvatarUrl } from "@kan/shared/utils";
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
getUser: protectedProcedure getUser: protectedProcedure
@@ -55,8 +56,12 @@ export const userRouter = createTRPCRouter({
const apiKey = result.apiKeys[0]; const apiKey = result.apiKeys[0];
// Generate presigned URL for avatar
const imageUrl = await generateAvatarUrl(result.image);
return { return {
...result, ...result,
image: imageUrl,
apiKey: apiKey ?? null, apiKey: apiKey ?? null,
}; };
}), }),
@@ -102,6 +107,12 @@ export const userRouter = createTRPCRouter({
}); });
} }
return result; // Generate presigned URL for avatar
const imageUrl = await generateAvatarUrl(result.image);
return {
...result,
image: imageUrl,
};
}), }),
}); });

View File

@@ -8,6 +8,7 @@ import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions"; import { assertPermission } from "../utils/permissions";
import { generateAvatarUrl } from "@kan/shared/utils";
export const workspaceRouter = createTRPCRouter({ export const workspaceRouter = createTRPCRouter({
all: protectedProcedure all: protectedProcedure
@@ -86,9 +87,27 @@ export const workspaceRouter = createTRPCRouter({
const shouldShowEmails = const shouldShowEmails =
isAdmin || result.showEmailsToMembers === true; isAdmin || result.showEmailsToMembers === true;
// Generate presigned URLs for member avatars
const membersWithAvatarUrls = await Promise.all(
result.members.map(async (member) => {
if (!member.user?.image) {
return member;
}
const avatarUrl = await generateAvatarUrl(member.user.image);
return {
...member,
user: {
...member.user,
image: avatarUrl,
},
};
}),
);
// If emails should be hidden, filter them out // If emails should be hidden, filter them out
if (!shouldShowEmails) { if (!shouldShowEmails) {
const sanitizedMembers = result.members.map((member) => { const sanitizedMembers = membersWithAvatarUrls.map((member) => {
// If user doesn't have a display name, use anonymous identifier // If user doesn't have a display name, use anonymous identifier
const displayName = const displayName =
member.user?.name?.trim() ?? `anonymous_${member.publicId}`; member.user?.name?.trim() ?? `anonymous_${member.publicId}`;
@@ -120,7 +139,10 @@ export const workspaceRouter = createTRPCRouter({
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>; } as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
} }
return result; return {
...result,
members: membersWithAvatarUrls,
};
}), }),
bySlug: publicProcedure bySlug: publicProcedure
.meta({ .meta({
@@ -138,7 +160,7 @@ export const workspaceRouter = createTRPCRouter({
workspaceSlug: z workspaceSlug: z
.string() .string()
.min(3) .min(3)
.max(24) .max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/), .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}), }),
) )
@@ -185,7 +207,7 @@ export const workspaceRouter = createTRPCRouter({
slug: z slug: z
.string() .string()
.min(3) .min(3)
.max(24) .max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/) .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(), .optional(),
}), }),
@@ -268,7 +290,7 @@ export const workspaceRouter = createTRPCRouter({
slug: z slug: z
.string() .string()
.min(3) .min(3)
.max(24) .max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/) .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(), .optional(),
description: z.string().min(3).max(280).optional(), description: z.string().min(3).max(280).optional(),
@@ -402,7 +424,7 @@ export const workspaceRouter = createTRPCRouter({
workspaceSlug: z workspaceSlug: z
.string() .string()
.min(3) .min(3)
.max(24) .max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/), .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}), }),
) )

Some files were not shown because too many files have changed in this diff Show More