Compare commits

..

71 Commits

Author SHA1 Message Date
Henry
d3687cfb10 fix: API key authentication and REST handler errors 2026-03-18 22:46:30 +00:00
hjball
0b45ceff7f chore: update translations 2026-03-16 14:32:37 +00:00
Henry
76b2c58461 fix: security improvements to checklist input (#444)
* feat: sanitize input for checklists

* feat: add basic editor

* feat: use plain text editor for checklist items

* feat: remove key from user response
2026-03-16 14:31:13 +00:00
Henry
b8d1fe230d fix: prevent empty string from throwing error 2026-03-13 23:08:46 +00:00
hjball
bd652ba6c9 chore: update translations 2026-03-13 22:59:04 +00:00
Henry
1fb56aafd0 feat: add logger package to improve observability (#437)
* feat: add logger package to improve observability

* chore: add LOG_LEVEL to docker compose and readme
2026-03-13 22:57:47 +00:00
hjball
91deaca6ec chore: compile translations 2026-03-13 22:45:15 +00:00
hjball
ab81db0f56 chore: update translations 2026-03-13 22:45:11 +00:00
Andy Clemenko
29080fa851 Fontsize PR for Issue #429 (#430)
* adding font size

* removing trash

* moving font menu

* chore: revert port change in compose

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-03-13 22:43:50 +00:00
hjball
2360c5075d chore: compile translations 2026-03-11 20:06:56 +00:00
hjball
79c2b5f3d5 chore: update translations 2026-03-11 20:06:52 +00:00
Matt
8e7a95ff2b feat: workspace start of week column (closes #361) (#399)
* feat: workspace start of week column

* feat(l10n): add workspace setting for the first day of the week

Fixes #361

* feat: add Saturday as option

* chore: fix migration order

* chore: fix merge

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-03-11 20:05:03 +00:00
hjball
94fd2cb11f chore: update translations 2026-03-10 22:45:30 +00:00
Henry
2a8af514d1 chore: add missing env vars to docker compose 2026-03-10 22:44:09 +00:00
hjball
0b405e0456 chore: compile translations 2026-03-10 22:35:57 +00:00
hjball
f1f3a00c17 chore: update translations 2026-03-10 22:35:53 +00:00
wotan-allfather
0fb2bac102 fix: add 404 not found page (#349)
* fix: add 404 not found page

Closes #320

- Add custom 404 page matching the app's design system
- Include PatternedBackground and proper dark mode support
- Provide navigation links to homepage and boards
- Add i18n support with @lingui/react

* fix: add 404 redirects for non-existent boards and cards

Add useEffect hooks to redirect to /404 page when:
- Board does not exist in board view
- Card does not exist in card view

This addresses the cases mentioned in maintainer feedback where
the static 404 page wasn't being used for dynamic routes.

Addresses review feedback on PR #349

* feat: redirect to 404 for non-existent public workspaces and cards

Per maintainer feedback:
- Add redirect logic to public boards view when workspace doesn't exist
- Add redirect logic to public card modal when card doesn't exist
- Uses error?.data?.code === 'NOT_FOUND' check as suggested
- Closes modal before redirecting for card modal
2026-03-10 22:34:28 +00:00
hjball
a7b71ae764 chore: compile translations 2026-03-10 22:33:49 +00:00
hjball
a246514b0b chore: update translations 2026-03-10 22:33:45 +00:00
Henry
53397d8e81 feat: add url validation to download attatchment endpoint (#432) 2026-03-10 22:31:59 +00:00
Nick Meinhold
1f9f07df20 feat(web): add webhook management UI (#394)
* feat(api): add webhook CRUD API router and tests

Add tRPC router for managing workspace webhooks:

- list, create, update, delete endpoints (admin role required)
- test endpoint to send a synthetic payload to a webhook URL
- URL validation, event subscription filtering
- Unit tests for all router procedures
- Integration tests with PGlite test database
- Add vitest config and test infrastructure for API package

Depends on #391 (DB schema & repository).

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

* refactor(api): use assertPermission instead of assertUserInWorkspace

Replace assertUserInWorkspace with assertPermission("workspace:manage")
per project conventions. The permissions system is the preferred
authorization approach for new code.

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

* fix(api): use @kan/db alias instead of relative imports in tests

Replace relative path imports (../../db/src/...) with the @kan/db
alias configured in vitest.config.ts for consistency and robustness.

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

* refactor(api): use webhookUrlSchema in router input validation

Cherry-pick router-related changes from b2cc9ac:
- Use extracted webhookUrlSchema zod validator in create/update
  input schemas for consistent SSRF checks

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

* refactor(api): replace dynamic import with static import for webhook utility

Add packages/api/src/utils/webhook.ts with sendWebhookToUrl,
createCardWebhookPayload, and webhookUrlSchema. Replace the dynamic
import() in the test endpoint with a static import at the top of the
file for better tree-shaking, type-checking, and readability.

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

* fix(api): align sendWebhooksForWorkspace tests with merged PR #392

The merged delivery utility uses client-side event filtering
(getActiveByWorkspaceId takes 2 args, not 3). Update test assertions
to match the actual implementation.

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

* feat(web): add webhook management UI

Add settings page for managing workspace webhooks:

- Add webhooks page route and settings navigation link
- Add webhook list view with status toggles and action menus
- Add create/edit modal with URL validation and event selection
- Add delete confirmation dialog
- Add WEBHOOKS_ENABLED env flag for feature gating

Depends on #393 (CRUD API router).

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

* fix(web): remove dead env vars, extract TableRow, import webhookEvents

- Remove unused WEBHOOK_URL and WEBHOOK_SECRET env vars (leftovers
  from earlier env-var-based design)
- Move TableRow component outside WebhookList to avoid re-creation
  on every render
- Import webhookEvents from @kan/db/schema instead of hardcoding
- Simplify formatDate to only handle Date objects (strings are not
  returned by tRPC/Superjson)

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

* fix(web): gate webhooks settings tab to admin role

The webhook API requires admin role, but the settings tab was visible
to all users (condition: true). Now matches the API's authorization
requirement, addressing reviewer feedback on PR #394.

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

* refactor(web): use webhookEvents constant for form defaults

Replace hardcoded event arrays with [...webhookEvents] in
NewWebhookModal so default values stay in sync if new events
are added to the schema.

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

* fix(web): use date-fns with locale for webhook date formatting

Replace hardcoded toLocaleDateString('en-US') with date-fns format()
using the useLocalisation() hook's dateLocale, matching the pattern
used throughout the codebase (ActivityList, DateSelector, etc.).

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:31:45 +00:00
Nick Meinhold
1d5e3a936c feat(api): add webhook CRUD API router and tests (#393)
* feat(api): add webhook CRUD API router and tests

Add tRPC router for managing workspace webhooks:

- list, create, update, delete endpoints (admin role required)
- test endpoint to send a synthetic payload to a webhook URL
- URL validation, event subscription filtering
- Unit tests for all router procedures
- Integration tests with PGlite test database
- Add vitest config and test infrastructure for API package

Depends on #391 (DB schema & repository).

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

* refactor(api): use assertPermission instead of assertUserInWorkspace

Replace assertUserInWorkspace with assertPermission("workspace:manage")
per project conventions. The permissions system is the preferred
authorization approach for new code.

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

* fix(api): use @kan/db alias instead of relative imports in tests

Replace relative path imports (../../db/src/...) with the @kan/db
alias configured in vitest.config.ts for consistency and robustness.

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

* refactor(api): use webhookUrlSchema in router input validation

Cherry-pick router-related changes from b2cc9ac:
- Use extracted webhookUrlSchema zod validator in create/update
  input schemas for consistent SSRF checks

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

* refactor(api): replace dynamic import with static import for webhook utility

Add packages/api/src/utils/webhook.ts with sendWebhookToUrl,
createCardWebhookPayload, and webhookUrlSchema. Replace the dynamic
import() in the test endpoint with a static import at the top of the
file for better tree-shaking, type-checking, and readability.

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

* fix(api): align sendWebhooksForWorkspace tests with merged PR #392

The merged delivery utility uses client-side event filtering
(getActiveByWorkspaceId takes 2 args, not 3). Update test assertions
to match the actual implementation.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Henry <30578846+hjball@users.noreply.github.com>
2026-03-10 22:27:52 +00:00
Thomas Goetten
0b49f502a9 feat: make avatar upload size limit configurable via environment variable (#375) 2026-03-10 22:16:20 +00:00
Amin NAIRI
400dcec56d Allow installing this app as a Web App (#419)
* added an icon of 512x512 for the manifest

* added a web app manifest

* added a screenshot for richer install on mobile

* added a screenshot for richer install ui on desktop

* added a document to include the manifest

* fixed the command to generate a better auth secret

* moved the link tag directly into the PageHead component
2026-03-10 22:13:58 +00:00
hjball
dfcdc5e47e chore: update translations 2026-03-10 22:00:41 +00:00
kOaDT
32e77e0291 fix: preserve board scroll position when navigating back from a card (#431) 2026-03-10 21:59:22 +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
156 changed files with 84892 additions and 27693 deletions

View File

@@ -1,18 +1,62 @@
# Environment
.env
.env.*
!.env.example
docker-compose.override.yml
Dockerfile
./**/*/Dockerfile
# Docker
docker-compose*.yml
.dockerignore
# Dependencies (rebuilt in Docker)
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
README.md
.next
# .git
# Cloud compose (separate deployment)
cloud/

View File

@@ -2,7 +2,7 @@
# Required environment variables
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 26 | tr -dc 'a-zA-Z0-9' | head -c 32)
# Fill if you want to use an external database
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
@@ -28,12 +28,14 @@ S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=
# S3_AVATAR_UPLOAD_LIMIT=2097152 # Avatar upload size limit in bytes (default: 2MB)
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN=
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
# Auth config (optional)
NEXT_PUBLIC_ALLOW_CREDENTIALS=
NEXT_PUBLIC_DISABLE_SIGN_UP=
@@ -49,6 +51,9 @@ TRELLO_APP_SECRET=
# If not provided, rate limiting will use in-memory storage
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
LOG_LEVEL=
# OAuth providers (optional)
BETTER_AUTH_TRUSTED_ORIGINS=
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)

View File

@@ -6,23 +6,31 @@ name: Docker
# documentation.
on:
schedule:
- cron: "21 21 * * *"
push:
branches: ["main"]
# Publish semver tags as releases.
# Only trigger on tags (releases), not main branch pushes
# Main branch builds are handled by workflow_run after Translate completes
tags: ["v*.*.*"]
pull_request:
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:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
jobs:
build:
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
runs-on: ubuntu-latest
permissions:
contents: read
@@ -74,7 +82,22 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
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
# 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-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.
# 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
@@ -143,3 +182,10 @@ jobs:
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
- 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

2
.gitignore vendored
View File

@@ -54,3 +54,5 @@ i18n.cache
# pgdata
/apps/web/pgdata
.claude

View File

@@ -185,9 +185,17 @@ Use `assertUserInWorkspace` helper for workspace checks.
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
- Provide user-friendly error messages
- Log errors appropriately
- Log errors appropriately using the `@kan/logger` package
- Show popup notifications for user-facing errors
### Logging
- Import from `@kan/logger`: `import { createLogger } from "@kan/logger"`
- Create a module-scoped logger: `const logger = createLogger("module-name")`
- Log level is controlled by `LOG_LEVEL` env var (debug, info, warn, error)
- Defaults to `debug` in development, `info` in production
- Never use `console.log` — always use the logger
## Common Patterns
### Creating a Card
@@ -219,6 +227,16 @@ Use `assertUserInWorkspace` helper for workspace checks.
5. **Frontend**: Add UI components in `apps/web/src/`
6. **i18n**: Add translations for new strings
## Adding a New Environment Variable
Update all of the following:
1. `.env.example` — add the variable with an empty value and a comment explaining it
2. `turbo.json` — add to `globalEnv` (or `globalPassThroughEnv` for CI/platform vars)
3. `docker-compose.yml` — add to the `web` service `environment` section
4. `cloud/docker-compose.yml` — add to the `web` service `environment` section
5. `README.md` — add a row to the Environment Variables table
## Database Changes
- Always create migrations (never modify existing migrations)

View File

@@ -57,39 +57,61 @@ The easiest way to deploy Kan is through Railway. We've partnered with Railway t
### Docker Compose
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database.
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 new file called `docker-compose.yml` and paste the following configuration:
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
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:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
ports:
- "3000:3000"
- "${WEB_PORT:-3000}:3000"
networks:
- kan-network
env_file:
- .env
environment:
NEXT_PUBLIC_BASE_URL: http://localhost:3000
BETTER_AUTH_SECRET: your_auth_secret
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
depends_on:
- postgres
migrate:
condition: service_completed_successfully
restart: unless-stopped
postgres:
image: postgres:15
container_name: kan-db
environment:
POSTGRES_DB: kan_db
POSTGRES_USER: kan
POSTGRES_PASSWORD: your_postgres_password
- POSTGRES_DB=kan_db
- POSTGRES_USER=kan
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ports:
- 5432:5432
volumes:
- 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
networks:
- kan-network
@@ -101,23 +123,23 @@ volumes:
kan_postgres_data:
```
2. Start the containers in detached mode:
3. Start the containers in detached mode:
```bash
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 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 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.
> **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.
For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
## Local Development 🧑‍💻
@@ -181,6 +203,7 @@ pnpm dev
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `S3_AVATAR_UPLOAD_LIMIT` | Maximum avatar file size in bytes | For file uploads | `2097152` (2MB) |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
@@ -190,6 +213,7 @@ pnpm dev
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
| `LOG_LEVEL` | Log verbosity level (debug, info, warn, error) | No (defaults to debug in dev, info in prod) | `info` |
See `.env.example` for a complete list of supported environment variables.

View File

@@ -11,10 +11,10 @@ https://kan.bn/api/v1
## Authentication
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key in the `x-api-key` header of each request.
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key as a Bearer token in the `Authorization` header of each request.
```
'x-api-key': kan_123456789
'Authorization': 'Bearer kan_123456789'
```
## Response codes

View File

@@ -1,25 +1,23 @@
ARG NODE_VERSION=20
ARG APP_DIRNAME=web
ARG PROJECT=@kan/web
# syntax=docker/dockerfile:1.7
# 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
RUN apk update && \
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
rm -rf /var/cache/apk/* /tmp/* || true
apk add --no-cache libc6-compat && \
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
RUN corepack enable
# Replace <your-major-version> with the major version installed in your repository. For example:
# 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
# ============================================
# Stage 2: Prune the monorepo
# ============================================
FROM alpine AS pruner
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 "$AUTO_VERSION" > /app/AUTO_VERSION
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
# 3. Build the project
FROM base AS builder
ARG PROJECT
ARG APP_VERSION
RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
# ============================================
# Stage 3: Install dependencies
# ============================================
FROM alpine AS deps
WORKDIR /app
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/json/ .
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
ENV CI=true
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ .
# 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')}" && \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
# # Copy static files to standalone directory
# RUN mkdir -p apps/web/.next/standalone/.next && \
# mv apps/web/public apps/web/.next/standalone/ && \
# mv apps/web/.next/static apps/web/.next/standalone/.next/
# 4. Final image - runner stage to run the application
FROM base AS runner
ARG APP_DIRNAME
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
# ============================================
# Stage 4: Build the web application
# ============================================
FROM alpine AS builder
ARG APP_VERSION
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 PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --chown=nextjs:nodejs --from=builder /app/ ./
WORKDIR /app/apps/${APP_DIRNAME}
# Copy the standalone Next.js server
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
ENV PORT=${PORT}
EXPOSE ${PORT}
# Copy bootstrap script for runtime env var injection
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
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": {
"source": "en",
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
},
"buckets": {
"po": {
"include": ["src/locales/[locale]/messages.po"]
"json": {
"include": ["src/locales/[locale]/messages.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",
catalogs: [
{
@@ -10,6 +12,5 @@ const config: LinguiConfig = {
exclude: ["**/node_modules/**"],
},
],
};
export default config;
format: formatter({ style: "lingui" }),
});

View File

@@ -16,6 +16,18 @@ const config = {
: undefined,
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 */
transpilePackages: [
"@kan/api",
@@ -35,49 +47,13 @@ const config = {
remotePatterns: (() => {
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
const patterns = [
{
protocol: "https",
hostname:
env("S3_FORCE_PATH_STYLE") === "true"
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
},
{ protocol: "https", hostname: "**" },
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "https",
hostname: "*.googleusercontent.com",
},
{
protocol: 'https',
hostname: 'cdn.discordapp.com',
},
];
// 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;
})(),
},

View File

@@ -27,6 +27,7 @@
"@kan/api": "workspace:*",
"@kan/auth": "workspace:*",
"@kan/db": "workspace:^",
"@kan/logger": "workspace:^",
"@kan/shared": "workspace:^",
"@lingui/babel-preset-react": "^2.9.2",
"@lingui/conf": "^5.3.2",
@@ -79,6 +80,7 @@
"@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*",
"@lingui/cli": "^5.3.2",
"@lingui/format-json": "^5.9.1",
"@lingui/loader": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2",
"@svgr/webpack": "^8.1.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,39 @@
{
"background_color": "#000000",
"categories": [
"business",
"productivity",
"utilities"
],
"description": "The open source Trello alternative.",
"display": "standalone",
"icons": [
{
"sizes": "512x512",
"src": "/icon-512.png",
"type": "image/png",
"purpose": "any"
}
],
"id": "/",
"screenshots": [
{
"sizes": "1624x1561",
"src": "/screenshot-wide.png",
"type": "image/png",
"form_factor": "wide"
},
{
"sizes": "1290x2796",
"src": "/screenshot-narrow.png",
"type": "image/png",
"form_factor": "narrow"
}
],
"orientation": "portrait-primary",
"name": "Kan.bn | The open source alternative to Trello",
"scope": "/",
"short_name": "Kan.bn",
"start_url": "/",
"theme_color": "#000000"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -145,7 +145,6 @@ export default function CommandPallette({
value={result}
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
onClick={() => {
console.log("clicked", url);
void router.push(url);
onClose();
setQuery("");

View File

@@ -17,9 +17,14 @@ import { twMerge } from "tailwind-merge";
interface DateSelectorProps {
selectedDate?: Date | null;
onDateSelect?: (date: Date | undefined) => void;
weekStartsOn?: 0 | 1 | 6;
}
const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
const DateSelector = ({
selectedDate,
onDateSelect,
weekStartsOn = 1,
}: DateSelectorProps) => {
const [currentMonth, setCurrentMonth] = useState(() => {
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
});
@@ -28,18 +33,18 @@ const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
const year = format(currentMonth, "yyyy");
const dayHeaders = useMemo(() => {
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
const weekStart = startOfWeek(new Date(), { weekStartsOn });
return eachDayOfInterval({
start: weekStart,
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
}, []);
}, [weekStartsOn]);
const days = useMemo(() => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
const calendarStart = startOfWeek(monthStart, { weekStartsOn });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
(date) => {
@@ -53,7 +58,7 @@ const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
};
},
);
}, [currentMonth, selectedDate]);
}, [currentMonth, selectedDate, weekStartsOn]);
const handlePreviousMonth = () => {
setCurrentMonth(subMonths(currentMonth, 1));

View File

@@ -463,13 +463,6 @@ export default function Editor({
StarterKit.configure({
heading: disableHeadings ? false : undefined,
}),
Markdown,
Placeholder.configure({
placeholder: readOnly
? ""
: placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`,
}),
Link.configure({
openOnClick: true,
HTMLAttributes: {
@@ -479,6 +472,14 @@ export default function Editor({
},
validate: (href) => /^https?:\/\//.test(href),
autolink: true,
linkOnPaste: true,
}),
Markdown,
Placeholder.configure({
placeholder: readOnly
? ""
: placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`,
}),
SlashCommands.configure({
commandItems: getCommandItems(disableHeadings),

View File

@@ -0,0 +1,32 @@
import { t } from "@lingui/core/macro";
import { HiOutlineAdjustmentsHorizontal } from "react-icons/hi2";
import { useFontSize, type FontSize } from "~/providers/font-size";
const fontSizeOptions: { value: FontSize; label: () => string }[] = [
{ value: "small", label: () => t`Small` },
{ value: "medium", label: () => t`Medium` },
{ value: "large", label: () => t`Large` },
];
export function FontSizeSelector() {
const { fontSize, setFontSize } = useFontSize();
return (
<div className="relative">
<HiOutlineAdjustmentsHorizontal className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<select
id="font-size-select"
value={fontSize}
onChange={(e) => setFontSize(e.target.value as FontSize)}
className="block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 text-sm shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500"
>
{fontSizeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label()}
</option>
))}
</select>
</div>
);
}

View File

@@ -75,7 +75,7 @@ export function LabelForm({
reset(newFormState);
}
} catch (e) {
console.log(e);
console.error(e);
}
},
});
@@ -231,9 +231,7 @@ export function LabelForm({
<Button
type="submit"
isLoading={updateLabel.isPending || createLabel.isPending}
disabled={
!watch("name")
}
disabled={!watch("name")}
>
{isEdit ? t`Update label` : t`Create label`}
</Button>

View File

@@ -33,7 +33,7 @@ const schema = z.object({
.min(3, {
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-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`,
})
@@ -111,6 +111,7 @@ export function NewWorkspaceForm() {
slug: values.slug,
plan: values.plan,
role: "admin",
weekStartDay: 1,
});
// If in cloud and Pro toggle is enabled, create checkout session for pro

View File

@@ -8,6 +8,7 @@ export const PageHead = ({ title }: { title: string }) => {
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<link rel="manifest" href="/manifest.json" />
</Head>
);
};

View File

@@ -0,0 +1,130 @@
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useRef } from "react";
import { twMerge } from "tailwind-merge";
interface PlainTextEditorProps {
content: string;
onChange?: (value: string) => void;
onBlur?: (value: string) => void;
onEnter?: (value: string) => void;
onEscape?: () => void;
readOnly?: boolean;
placeholder?: string;
className?: string;
}
export default function PlainTextEditor({
content,
onChange,
onBlur,
onEnter,
onEscape,
readOnly = false,
placeholder,
className,
}: PlainTextEditorProps) {
const onEnterRef = useRef(onEnter);
const onEscapeRef = useRef(onEscape);
const onBlurRef = useRef(onBlur);
const onChangeRef = useRef(onChange);
const contentRef = useRef(content);
useEffect(() => {
onEnterRef.current = onEnter;
}, [onEnter]);
useEffect(() => {
onEscapeRef.current = onEscape;
}, [onEscape]);
useEffect(() => {
onBlurRef.current = onBlur;
}, [onBlur]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
contentRef.current = content;
}, [content]);
const editor = useEditor(
{
extensions: [
StarterKit.configure({
bold: false,
italic: false,
strike: false,
code: false,
codeBlock: false,
blockquote: false,
heading: false,
bulletList: false,
orderedList: false,
listItem: false,
horizontalRule: false,
hardBreak: false,
}),
Placeholder.configure({ placeholder }),
],
content,
editable: !readOnly,
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getText()),
onBlur: ({ editor }) => onBlurRef.current?.(editor.getText()),
editorProps: {
handleKeyDown: (view, event) => {
if (event.key === "Enter") {
event.preventDefault();
onEnterRef.current?.(view.state.doc.textContent.trim());
return true;
}
if (event.key === "Escape") {
event.preventDefault();
// Reset to original content before calling the callback
editor?.commands.setContent(contentRef.current, false);
editor?.commands.blur();
onEscapeRef.current?.();
return true;
}
return false;
},
attributes: {
class: "outline-none focus:outline-none focus-visible:ring-0",
},
},
},
[],
);
useEffect(() => {
if (!editor) return;
if (content !== editor.getText()) {
editor.commands.setContent(content, false);
}
}, [content, editor]);
useEffect(() => {
if (!editor) return;
editor.setEditable(!readOnly);
}, [readOnly, editor]);
return (
<>
<style jsx global>{`
.plain-text-editor p.is-empty::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
color: var(--placeholder-color, #9ca3af);
}
.plain-text-editor .tiptap p {
margin: 0 !important;
}
`}</style>
<EditorContent
editor={editor}
className={twMerge("plain-text-editor", className)}
/>
</>
);
}

View File

@@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
import {
HiChevronDown,
HiOutlineBanknotes,
HiOutlineBolt,
HiOutlineCodeBracketSquare,
HiOutlineRectangleGroup,
HiOutlineShieldCheck,
@@ -64,6 +65,12 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
label: t`API`,
condition: true,
},
{
key: "webhooks",
icon: <HiOutlineBolt />,
label: t`Webhooks`,
condition: isAdmin,
},
{
key: "integrations",
icon: <HiOutlineCodeBracketSquare />,

View File

@@ -19,6 +19,7 @@ interface UsePermissionsResult {
canCreateBoard: boolean;
canEditBoard: boolean;
canDeleteBoard: boolean;
canArchiveBoard: boolean;
canViewComment: boolean;
canCreateComment: boolean;
canEditComment: boolean;
@@ -33,7 +34,7 @@ interface UsePermissionsResult {
export function usePermissions(): UsePermissionsResult {
// Check if WorkspaceProvider is available (for public board views, it may not be)
const workspaceContext = useContext(WorkspaceContext);
// If WorkspaceProvider is not available, return safe defaults
if (!workspaceContext) {
const emptyPermissions: UsePermissionsResult = {
@@ -51,6 +52,7 @@ export function usePermissions(): UsePermissionsResult {
canCreateBoard: false,
canEditBoard: false,
canDeleteBoard: false,
canArchiveBoard: false,
canViewComment: false,
canCreateComment: false,
canEditComment: false,
@@ -95,6 +97,7 @@ export function usePermissions(): UsePermissionsResult {
canCreateBoard: hasPermission("board:create"),
canEditBoard: hasPermission("board:edit"),
canDeleteBoard: hasPermission("board:delete"),
canArchiveBoard: hasPermission("board:edit"),
canViewComment: hasPermission("comment:view"),
canCreateComment: hasPermission("comment:create"),
canEditComment: hasPermission("comment:edit"),

View File

@@ -0,0 +1,44 @@
import type { RefObject } from "react";
import type { NextRouter } from "next/router";
import { useEffect, useRef } from "react";
const scrollPositions = new Map<string, number>();
export function useScrollRestore(
boardId: string | null | undefined,
scrollRef: RefObject<HTMLElement | null>,
router: NextRouter,
isReady: boolean,
) {
const restored = useRef(false);
useEffect(() => {
if (!boardId) return;
restored.current = false;
const saveScrollPosition = () => {
if (scrollRef.current) {
scrollPositions.set(boardId, scrollRef.current.scrollLeft);
}
};
router.events.on("routeChangeStart", saveScrollPosition);
return () => router.events.off("routeChangeStart", saveScrollPosition);
}, [boardId, router.events, scrollRef]);
useEffect(() => {
if (restored.current || !isReady || !boardId) return;
restored.current = true;
const saved = scrollPositions.get(boardId);
if (saved === undefined) return;
// StrictModeDroppable delays rendering by one requestAnimationFrame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollLeft = saved;
});
});
}, [isReady, scrollRef, boardId]);
}

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

@@ -0,0 +1,55 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
export default function NotFoundPage() {
return (
<>
<PageHead title={t`404 - Page Not Found | kan.bn`} />
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
<div className="z-10 flex w-full flex-col items-center">
<Link href="/">
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
kan.bn
</h1>
</Link>
<p className="mb-4 text-8xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
404
</p>
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
<Trans>Page not found</Trans>
</p>
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<p className="mb-6 text-center text-light-900 dark:text-dark-900">
<Trans>
The page you're looking for doesn't exist or has been moved.
</Trans>
</p>
<div className="flex flex-col gap-3">
<Link
href="/"
className="flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-100 shadow-sm hover:bg-light-900 dark:bg-dark-1000 dark:text-dark-100 dark:hover:bg-dark-900"
>
<Trans>Go to homepage</Trans>
</Link>
<Link
href="/boards"
className="flex w-full justify-center rounded-md border border-light-500 bg-light-100 px-3 py-2 text-sm font-semibold text-light-1000 shadow-sm hover:bg-light-200 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 dark:hover:bg-dark-300"
>
<Trans>Go to boards</Trans>
</Link>
</div>
</div>
</div>
</div>
<PatternedBackground />
</div>
</main>
</>
);
}

View File

@@ -12,6 +12,7 @@ import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
import { FontSizeProvider } from "~/providers/font-size";
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
import { LinguiProviderWrapper } from "~/providers/lingui";
import { ModalProvider } from "~/providers/modal";
@@ -83,19 +84,21 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
<main className="font-sans">
<KeyboardShortcutProvider>
<LinguiProviderWrapper>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
{getLayout(<Component {...pageProps} />)}
</PostHogProvider>
) : (
getLayout(<Component {...pageProps} />)
)}
</PopupProvider>
</ModalProvider>
</ThemeProvider>
<FontSizeProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
{getLayout(<Component {...pageProps} />)}
</PostHogProvider>
) : (
getLayout(<Component {...pageProps} />)
)}
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</FontSizeProvider>
</LinguiProviderWrapper>
</KeyboardShortcutProvider>
</main>

View File

@@ -2,48 +2,74 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { env } from "~/env";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
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",
});
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const contentType =
upstream.headers.get("Content-Type") ?? "application/octet-stream";
const { url, filename } = req.query;
res.setHeader("Content-Type", contentType);
res.setHeader(
"Content-Disposition",
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
);
if (!url || typeof url !== "string") {
return res.status(400).json({ message: "url parameter is required" });
}
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" });
}
const s3Endpoint = env.S3_ENDPOINT;
if (s3Endpoint) {
let parsed: URL;
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
.string()
.min(3)
.max(24)
.max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
interface CheckoutSessionRequest {

View File

@@ -3,8 +3,11 @@ import type { Readable } from "node:stream";
import { createNextApiContext } from "@kan/api/trpc";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { createStripeClient } from "@kan/stripe";
const log = createLogger("stripe-webhook");
async function buffer(readable: Readable) {
const chunks = [];
for await (const chunk of readable) {
@@ -41,6 +44,8 @@ export default async function handler(
const { db } = await createNextApiContext(req);
log.info({ eventType: event.type, eventId: event.id }, "Stripe webhook received");
switch (event.type) {
case "checkout.session.completed": {
const checkoutSession = event.data.object;
@@ -56,12 +61,12 @@ export default async function handler(
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
log.warn({ eventType: event.type }, "Unhandled Stripe event type");
}
return res.status(200).json({ received: true });
} catch (err) {
console.error("Webhook error:", err);
log.error({ err }, "Stripe webhook handler failed");
return res.status(400).json({ message: "Webhook handler failed" });
}
}

View File

@@ -8,7 +8,7 @@ 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 MAX_SIZE_BYTES = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
export const config = {

View File

@@ -1,24 +1,25 @@
import { useEffect } from "react";
export default function TrelloAuthorize() {
useEffect(() => {
const hash = window.location.hash;
const token = hash.split("=")[1];
if (token) {
console.log("Posting token to /api/trello/authenticate", token);
fetch("/api/trello/authenticate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
}).then(() => {
window.close();
});
}
}, []);
useEffect(() => {
const hash = window.location.hash;
const token = hash.split("=")[1];
if (token) {
fetch("/api/trello/authenticate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
}).then(() => {
window.close();
});
}
}, []);
return <div className="flex h-[200px] items-center justify-center">
<p className="text-center">Connecting to Trello...</p>
</div>;
}
return (
<div className="flex h-[200px] items-center justify-center">
<p className="text-center">Connecting to Trello...</p>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import WebhookSettings from "~/views/settings/WebhookSettings";
const WebhookSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="webhooks">
<WebhookSettings />
</SettingsLayout>
);
};
WebhookSettingsPage.getLayout = (page) => getDashboardLayout(page);
export default WebhookSettingsPage;

View File

@@ -0,0 +1,57 @@
import React, {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
export type FontSize = "small" | "medium" | "large";
const fontSizeMap: Record<FontSize, string> = {
small: "14px",
medium: "16px",
large: "18px",
};
interface FontSizeContextProps {
fontSize: FontSize;
setFontSize: (size: FontSize) => void;
}
const FontSizeContext = createContext<FontSizeContextProps | undefined>(
undefined,
);
export const FontSizeProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [fontSize, setFontSizeState] = useState<FontSize>("medium");
const setFontSize = (size: FontSize) => {
document.documentElement.style.fontSize = fontSizeMap[size];
localStorage.setItem("fontSize", size);
setFontSizeState(size);
};
useEffect(() => {
const stored = localStorage.getItem("fontSize") as FontSize | null;
const size = stored && fontSizeMap[stored] ? stored : "medium";
document.documentElement.style.fontSize = fontSizeMap[size];
setFontSizeState(size);
}, []);
return (
<FontSizeContext.Provider value={{ fontSize, setFontSize }}>
{children}
</FontSizeContext.Provider>
);
};
export const useFontSize = (): FontSizeContextProps => {
const context = useContext(FontSizeContext);
if (!context) {
throw new Error("useFontSize must be used within a FontSizeProvider");
}
return context;
};

View File

@@ -19,6 +19,7 @@ interface Workspace {
slug: string | undefined;
plan: "free" | "pro" | "enterprise" | undefined;
role: "admin" | "member" | "guest";
weekStartDay: 0 | 1 | 6;
}
const initialWorkspace: Workspace = {
@@ -28,13 +29,14 @@ const initialWorkspace: Workspace = {
slug: "",
plan: "free",
role: "member",
weekStartDay: 1,
};
const initialAvailableWorkspaces: Workspace[] = [];
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
undefined,
);
export const WorkspaceContext = createContext<
WorkspaceContextProps | undefined
>(undefined);
export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
children,
@@ -79,6 +81,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
slug: workspace.slug,
description: workspace.description,
plan: workspace.plan,
weekStartDay: workspace.weekStartDay,
hasLoaded: true,
})) as Workspace[];
@@ -100,6 +103,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
plan: selectedWorkspace.workspace.plan,
description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role,
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
});
if (workspacePublicId) {
@@ -119,6 +123,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
plan: primaryWorkspace.plan,
description: primaryWorkspace.description,
role: primaryWorkspaceRole,
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
});
}
}, [data, isLoading, workspacePublicId, router]);

View File

@@ -2,6 +2,10 @@
@tailwind components;
@tailwind utilities;
html {
font-size: 14px;
}
[contenteditable="true"]:empty:before {
content: attr(placeholder);
display: block;

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ import Toggle from "~/components/Toggle";
import { useModalFormState } from "~/hooks/useModalFormState";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
@@ -53,6 +54,7 @@ export function NewCardForm({
queryParams,
}: NewCardFormProps) {
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const { closeModal, openModal, modalStates, clearModalState } = useModal();
const utils = api.useUtils();
@@ -491,6 +493,7 @@ export function NewCardForm({
setValue("dueDate", date ?? null);
setIsDateSelectorOpen(false);
}}
weekStartsOn={workspace.weekStartDay}
/>
</div>
</>

View File

@@ -26,6 +26,7 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
import { Tooltip } from "~/components/Tooltip";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { useDragToScroll } from "~/hooks/useDragToScroll";
import { useScrollRestore } from "~/hooks/useScrollRestore";
import { usePermissions } from "~/hooks/usePermissions";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
@@ -124,11 +125,21 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
data: boardData,
isSuccess,
isLoading: isQueryLoading,
error,
} = api.board.byId.useQuery(queryParams, {
enabled: !!boardId,
placeholderData: keepPreviousData,
});
// Redirect to 404 if board doesn't exist
useEffect(() => {
if (router.isReady && boardId && !isQueryLoading) {
if (error?.data?.code === "NOT_FOUND" || (!boardData && !isQueryLoading)) {
router.replace("/404");
}
}
}, [router, boardId, isQueryLoading, error, boardData]);
const refetchBoard = async () => {
if (boardId) await utils.board.byId.refetch({ boardPublicId: boardId });
};
@@ -141,6 +152,8 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const isLoading = isInitialLoading || isQueryLoading;
useScrollRestore(boardId, scrollRef, router, !isLoading && (boardData?.lists.length ?? 0) > 0);
const updateListMutation = api.list.update.useMutation({
onMutate: async (args) => {
await utils.board.byId.cancel();
@@ -494,7 +507,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isTemplate={!!isTemplate}
isLoading={!boardData}
boardPublicId={boardId ?? ""}
workspacePublicId={workspace.publicId}
isArchived={boardData?.isArchived ?? false}
isFavorite={boardData?.favorite}
boardName={boardData?.name}
/>
@@ -598,13 +611,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
? `/templates/${boardId}/cards/${card.publicId}`
: `/cards/${card.publicId}`
}
className={`mb-2 flex !cursor-pointer flex-col ${
card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}

View File

@@ -10,7 +10,7 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
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 { openModal } = useModal();
const { canCreateBoard } = usePermissions();
@@ -26,6 +26,7 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
{
workspacePublicId: workspace.publicId,
type: isTemplate ? "template" : "regular",
archived: archived,
},
{ enabled: workspace.publicId ? true : false },
);
@@ -59,10 +60,10 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
<div className="flex flex-col items-center">
<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">
{t`No ${isTemplate ? "templates" : "boards"}`}
{archived ? t`No archived boards` : t`No ${isTemplate ? "templates" : "boards"}`}
</p>
<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>
</div>
<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">
<PatternedBackground />
<button
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"
}`}
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
>
{board.favorite ? (
<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" />
)}
</button>
<button
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"
}`}
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
>
{board.favorite ? (
<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" />
)}
</button>
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
{board.name}
</p>

View File

@@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { Fragment, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { FaTrello } from "react-icons/fa";
import { FaGithub, FaTrello } from "react-icons/fa";
import {
HiChevronUpDown,
HiMiniArrowTopRightOnSquare,
@@ -27,12 +27,23 @@ const integrationProviders: Record<
name: "Trello",
icon: <FaTrello />,
},
github: {
name: "GitHub",
icon: <FaGithub />,
},
};
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
const SelectSource = ({
handleNextStep,
}: {
handleNextStep: (provider: string) => void;
}) => {
const { data: integrations, refetch: refetchIntegrations } =
api.integration.providers.useQuery();
const { control, handleSubmit } = useForm({
const { data: githubStatus, refetch: refetchGithubStatus } =
api.integration.getGitHubStatus.useQuery();
const { control, handleSubmit, watch } = useForm({
defaultValues: {
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(() => {
const handleFocus = () => {
refetchIntegrations();
void refetchIntegrations();
void refetchGithubStatus();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [refetchIntegrations]);
}, [refetchIntegrations, refetchGithubStatus]);
const onSubmit = () => {
if (!hasIntegrations && trelloUrl) {
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
const selected = watch("source");
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 {
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">
{hasIntegrations ? (
integrations.map((integration, index) => (
availableIntegrations.map((integration, index) => (
<Listbox.Option
key={`source_${index}`}
className="relative cursor-default select-none px-1"
@@ -123,18 +147,32 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
</Listbox.Option>
))
) : (
<Listbox.Option
key="trello_placeholder"
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}
<span className="ml-2 block truncate text-sm">
{integrationProviders.trello?.name}
</span>
</div>
</Listbox.Option>
<>
<Listbox.Option
key="trello_placeholder"
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}
<span className="ml-2 block truncate text-sm">
{integrationProviders.trello?.name}
</span>
</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>
</Transition>
@@ -154,7 +192,159 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
!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.error(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>
</div>
</div>
@@ -196,7 +386,7 @@ const ImportTrello: React.FC = () => {
await refetchBoards();
closeModal();
} catch (e) {
console.log(e);
console.error(e);
}
},
onError: () => {
@@ -213,7 +403,7 @@ const ImportTrello: React.FC = () => {
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 boardIds = Object.keys(values).filter((key) => values[key] === true);
@@ -267,7 +457,7 @@ const ImportTrello: React.FC = () => {
return (
<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">
<Toggle
@@ -277,7 +467,7 @@ const ImportTrello: React.FC = () => {
const newState = !isSelectAllEnabled;
setIsSelectAllEnabled(newState);
for (const board of boards || []) {
for (const board of boards ?? []) {
setValue(board.id, newState);
}
}}
@@ -313,6 +503,7 @@ const ImportTrello: React.FC = () => {
export function ImportBoardsForm() {
const { closeModal } = useModal();
const [step, setStep] = useState(1);
const [provider, setProvider] = useState<string | null>(null);
return (
<div>
@@ -339,8 +530,16 @@ export function ImportBoardsForm() {
</button>
</div>
{step === 1 && <SelectSource handleNextStep={() => setStep(step + 1)} />}
{step === 2 && <ImportTrello />}
{step === 1 && (
<SelectSource
handleNextStep={(p) => {
setProvider(p);
setStep(step + 1);
}}
/>
)}
{step === 2 && provider === "trello" && <ImportTrello />}
{step === 2 && provider === "github" && <ImportGithub />}
</div>
);
}

View File

@@ -1,5 +1,12 @@
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
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 FeedbackModal from "~/components/FeedbackModal";
@@ -15,9 +22,15 @@ import { BoardsList } from "./components/BoardsList";
import { ImportBoardsForm } from "./components/ImportBoardsForm";
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 }) {
const { openModal, modalContentType, isOpen } = useModal();
const { workspace } = useWorkspace();
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
const { canCreateBoard } = usePermissions();
const { tooltipContent: createModalShortcutTooltipContent } =
@@ -34,7 +47,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
<PageHead
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">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`${isTemplate ? "Templates" : "Boards"}`}
@@ -115,9 +128,79 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
</Modal>
</>
<div className="flex h-full flex-row">
<BoardsList isTemplate={!!isTemplate} />
</div>
{!isTemplate ? (
<div className="flex h-full w-full flex-col">
<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>
</>
);

View File

@@ -1,11 +1,11 @@
import type { DraggableProvided } from "react-beautiful-dnd";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import ContentEditable from "react-contenteditable";
import { useState } from "react";
import { HiXMark } from "react-icons/hi2";
import { RiDraggable } from "react-icons/ri";
import { twMerge } from "tailwind-merge";
import PlainTextEditor from "~/components/PlainTextEditor";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
@@ -33,9 +33,7 @@ export default function ChecklistItemRow({
}: ChecklistItemRowProps) {
const utils = api.useUtils();
const { showPopup } = usePopup();
const [title, setTitle] = useState("");
const [completed, setCompleted] = useState(false);
const [completed, setCompleted] = useState(item.completed);
const updateItem = api.checklist.updateItem.useMutation({
onMutate: async (vars) => {
@@ -103,21 +101,6 @@ export default function ChecklistItemRow({
},
});
// Only resync from props when switching items to avoid clobbering edits
useEffect(() => {
setTitle(item.title);
setCompleted(item.completed);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item.publicId]);
const sanitizeHtmlToPlainText = (html: string): string =>
html
.replace(/<br\s*\/?>(\n)?/gi, "\n")
.replace(/<div><br\s*\/?><\/div>/gi, "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
const handleToggleCompleted = () => {
if (viewOnly) return;
setCompleted((prev) => !prev);
@@ -127,14 +110,8 @@ export default function ChecklistItemRow({
});
};
const commitTitle = (rawHtml: string) => {
if (viewOnly) return;
const plain = sanitizeHtmlToPlainText(rawHtml);
if (!plain || plain === item.title) {
setTitle(item.title);
return;
}
setTitle(plain);
const commitTitle = (plain: string) => {
if (!plain || plain === item.title) return;
updateItem.mutate({
checklistItemPublicId: item.publicId,
title: plain,
@@ -183,36 +160,26 @@ export default function ChecklistItemRow({
)}
/>
</label>
<div className="flex-1 pr-7">
<ContentEditable
html={title}
disabled={viewOnly}
onChange={(e) => setTitle(e.target.value)}
// @ts-expect-error - valid event
onBlur={(e: Event) => {
const innerHTML = (e.target as HTMLElement).innerHTML;
commitTitle(innerHTML);
<PlainTextEditor
key={item.publicId}
content={item.title}
readOnly={viewOnly}
placeholder={t`Add details...`}
onBlur={commitTitle}
onEnter={(plain) => {
commitTitle(plain);
onCreateNewItem?.();
}}
onEscape={() => undefined}
className={twMerge(
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950",
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 dark:text-dark-950",
viewOnly && "cursor-default",
)}
placeholder={t`Add details...`}
onKeyDown={(e) => {
if (viewOnly) return;
if (e.key === "Enter") {
e.preventDefault();
const innerHTML = (e.currentTarget as HTMLElement).innerHTML;
commitTitle(innerHTML);
onCreateNewItem?.();
}
if (e.key === "Escape") {
e.preventDefault();
setTitle(item.title);
}
}}
/>
</div>
{!viewOnly && (
<button
type="button"

View File

@@ -5,6 +5,7 @@ import { HiMiniPlus } from "react-icons/hi2";
import DateSelector from "~/components/DateSelector";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
@@ -22,6 +23,7 @@ export function DueDateSelector({
disabled = false,
}: DueDateSelectorProps) {
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [pendingDate, setPendingDate] = useState<Date | null | undefined>(
@@ -135,6 +137,7 @@ export function DueDateSelector({
<DateSelector
selectedDate={pendingDate ?? undefined}
onDateSelect={handleDateSelect}
weekStartsOn={workspace.weekStartDay}
/>
</div>
</>

View File

@@ -3,8 +3,8 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { IoChevronForwardSharp } from "react-icons/io5";
import { HiXMark } from "react-icons/hi2";
import { IoChevronForwardSharp } from "react-icons/io5";
import { authClient } from "@kan/auth/client";
@@ -185,11 +185,20 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0]
: router.query.cardId;
const { data: card, isLoading } = api.card.byId.useQuery(
const { data: card, isLoading, error } = api.card.byId.useQuery(
{ cardPublicId: cardId ?? "" },
{ enabled: !!cardId && cardId.length >= 12 },
);
// Redirect to 404 if card doesn't exist
useEffect(() => {
if (router.isReady && cardId && !isLoading) {
if (error?.data?.code === "NOT_FOUND" || (!card && !isLoading)) {
router.replace("/404");
}
}
}, [router, cardId, isLoading, error, card]);
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
const canEdit = canEditCard || isCreator;
@@ -302,7 +311,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
if (!cardId) return <></>;
return (
<>
<PageHead
@@ -345,7 +353,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
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-5 w-5" />
<HiXMark className="h-4 w-4" />
</Link>
</div>
</>

View File

@@ -8,10 +8,10 @@ type FrequencyValue = "monthly" | "annually";
interface PlanFeature {
key: string;
label: string;
free: string | boolean | { text: string; highlight?: string };
teams: string | boolean | { text: string; highlight?: string };
pro: string | boolean | { text: string; highlight?: string };
enterprise: string | boolean | { text: string; highlight?: string };
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 };
enterprise: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
}
interface FeatureSection {
@@ -71,34 +71,34 @@ const FeatureComparisonTable = ({
{
key: "boards",
label: t`Boards`,
free: { text: t`Unlimited boards`, highlight: "Unlimited" },
teams: { text: t`Unlimited boards`, highlight: "Unlimited" },
pro: { text: t`Unlimited boards`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited boards`, highlight: "Unlimited" },
free: { text: t`Unlimited boards`, highlightFirstWord: true },
teams: { text: t`Unlimited boards`, highlightFirstWord: true },
pro: { text: t`Unlimited boards`, highlightFirstWord: true },
enterprise: { text: t`Unlimited boards`, highlightFirstWord: true },
},
{
key: "members",
label: t`Members`,
free: { text: t`1 user`, highlight: "1" },
teams: { text: t`Per-seat pricing`, highlight: "Per-seat" },
pro: { text: t`Unlimited members`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited members`, highlight: "Unlimited" },
free: { text: t`1 user`, highlightFirstWord: true },
teams: { text: t`Per-seat pricing`, highlightFirstWord: true },
pro: { text: t`Unlimited members`, highlightFirstWord: true },
enterprise: { text: t`Unlimited members`, highlightFirstWord: true },
},
{
key: "file-uploads",
label: t`File uploads`,
free: { text: t`10mb file uploads`, highlight: "10mb" },
teams: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
pro: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
free: { text: t`10mb file uploads`, highlightFirstWord: true },
teams: { text: t`Unlimited file uploads`, highlightFirstWord: true },
pro: { text: t`Unlimited file uploads`, highlightFirstWord: true },
enterprise: { text: t`Unlimited file uploads`, highlightFirstWord: true },
},
{
key: "workspace-username",
label: t`Workspace username`,
free: { text: t`Default username`, highlight: "Default" },
teams: { text: t`Default username`, highlight: "Default" },
pro: { text: t`Custom username`, highlight: "Custom" },
enterprise: { text: t`Custom username`, highlight: "Custom" },
free: { text: t`Default username`, highlightFirstWord: true },
teams: { text: t`Default username`, highlightFirstWord: true },
pro: { text: t`Custom username`, highlightFirstWord: true },
enterprise: { text: t`Custom username`, highlightFirstWord: true },
},
];
@@ -236,9 +236,9 @@ const FeatureComparisonTable = ({
key: "rest-api",
label: t`API`,
free: { text: t`Limited API access` },
teams: { text: t`Full API access` },
pro: { text: t`Full API access` },
enterprise: { text: t`Full 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",
@@ -254,18 +254,18 @@ const FeatureComparisonTable = ({
{
key: "sso",
label: t`SSO`,
free: { text: t`Google SSO` },
teams: { text: t`Google SSO` },
pro: { text: t`Google SSO` },
enterprise: { text: t`Google SSO + SAML` },
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` },
teams: { text: t`Admin roles` },
pro: { text: t`Admin roles` },
enterprise: { text: t`Advanced 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 },
},
];
@@ -334,59 +334,37 @@ const FeatureComparisonTable = ({
{ id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" },
];
const isHighlightValue = (
value: string | boolean | { text: string; highlight?: string },
): value is { text: string; highlight?: string } => {
return typeof value === "object" && value !== null && "text" in value;
};
const shouldHighlight = (text: string): boolean => {
const lowerText = text.toLowerCase();
return ["unlimited", "multiple", "per-seat", "custom", "mentions", "full", "sso", "admin"].some(
(keyword) => lowerText.includes(keyword),
);
};
const CellValue = ({
value,
label,
}: {
value: string | boolean | { text: string; highlight?: string };
value: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
label: string;
}) => {
// Handle object with text and highlight
if (isHighlightValue(value)) {
const { text, highlight } = value;
const isUnlimited = shouldHighlight(text);
let displayText;
if (highlight) {
const parts = text.split(new RegExp(`(${highlight})`, "gi"));
displayText = (
<>
{parts.map((part, index) =>
part.toLowerCase() === highlight.toLowerCase() ? (
<span key={index} className="text-light-1000 dark:text-dark-1000">
{part}
</span>
) : (
<span key={index} className="text-light-950 dark:text-dark-800">
{part}
</span>
),
)}
</>
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>
);
} else {
displayText = text;
}
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle
className={twMerge(
"mt-0.5 h-4 w-4 shrink-0",
isUnlimited
highlight
? "text-light-1000 dark:text-dark-1000"
: "text-light-400 dark:text-dark-600",
)}
@@ -394,44 +372,18 @@ const FeatureComparisonTable = ({
<span
className={twMerge(
"text-sm font-medium",
isUnlimited
highlight
? "text-light-1000 dark:text-dark-950"
: "text-light-950 dark:text-dark-800",
)}
>
{displayText}
</span>
</div>
);
}
if (typeof value === "string") {
const isUnlimited = shouldHighlight(value);
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle
className={twMerge(
"mt-0.5 h-4 w-4 shrink-0",
isUnlimited
? "text-light-1000 dark:text-dark-1000"
: "text-light-400 dark:text-dark-600",
)}
/>
<span
className={twMerge(
"text-sm font-medium",
isUnlimited
? "text-light-1000 dark:text-dark-950"
: "text-light-950 dark:text-dark-800",
)}
>
{value}
{text}
</span>
</div>
);
}
if (value) {
// Boolean true - show checkmark icon
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" />
@@ -441,7 +393,7 @@ const FeatureComparisonTable = ({
</div>
);
}
// Boolean false - show cross icon
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" />
@@ -455,7 +407,7 @@ const FeatureComparisonTable = ({
const getFeatureValue = (
feature: PlanFeature,
planId: string,
): string | boolean | { text: string; highlight?: string } => {
): string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean } => {
switch (planId) {
case "free":
return feature.free;

View File

@@ -47,7 +47,7 @@ export function CardModal({
}
};
const { data, isLoading } = api.card.byId.useQuery(
const { data, isLoading, error } = api.card.byId.useQuery(
{
cardPublicId: cardPublicId ?? "",
},
@@ -56,6 +56,17 @@ export function CardModal({
},
);
// Redirect to 404 if card doesn't exist
useEffect(() => {
if (isOpen && cardPublicId && !isLoading) {
if (error?.data?.code === "NOT_FOUND" || (!data && !isLoading && error)) {
// Close modal first, then redirect
closeModal();
router.replace("/404");
}
}
}, [isOpen, cardPublicId, isLoading, error, data, closeModal, router]);
const labels = data?.labels ?? [];
const handleScroll = () => {

View File

@@ -1,5 +1,6 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { t } from "@lingui/core/macro";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
@@ -12,13 +13,22 @@ export default function PublicBoardsView() {
? router.query.workspaceSlug[0]
: router.query.workspaceSlug;
const { data, isLoading } = api.workspace.bySlug.useQuery(
const { data, isLoading, error } = api.workspace.bySlug.useQuery(
{
workspaceSlug: workspaceSlug ?? "",
},
{ enabled: !!workspaceSlug },
);
// Redirect to 404 if workspace doesn't exist
useEffect(() => {
if (router.isReady && workspaceSlug && !isLoading) {
if (error?.data?.code === "NOT_FOUND" || (!data && !isLoading)) {
router.replace("/404");
}
}
}, [router, workspaceSlug, isLoading, error, data]);
const BoardsList = ({
isLoading,
boards,

View File

@@ -3,6 +3,7 @@ import { env } from "next-runtime-env";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import { FontSizeSelector } from "~/components/FontSizeSelector";
import { LanguageSelector } from "~/components/LanguageSelector";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
@@ -54,6 +55,16 @@ export default function AccountSettings() {
<LanguageSelector />
</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`Font size`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Change the application font size.`}
</p>
<FontSizeSelector />
</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`Delete account`}

View File

@@ -1,9 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import { z } from "zod";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import Input from "~/components/Input";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
@@ -11,10 +15,28 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
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() {
const { modalContentType, isOpen } = useModal();
const { showPopup } = usePopup();
const {
register,
handleSubmit,
formState: { isDirty, errors },
reset,
} = useForm<GitHubTokenFormValues>({
resolver: zodResolver(githubTokenSchema),
defaultValues: {
token: "",
},
});
const {
data: integrations,
refetch: refetchIntegrations,
@@ -34,21 +56,25 @@ export default function IntegrationsSettings() {
},
);
const { data: githubStatus, refetch: refetchGithubStatus } =
api.integration.getGitHubStatus.useQuery();
useEffect(() => {
const handleFocus = () => {
refetchIntegrations();
void refetchIntegrations();
void refetchGithubStatus();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [refetchIntegrations]);
}, [refetchIntegrations, refetchGithubStatus]);
const { mutateAsync: disconnectTrello } =
api.integration.disconnect.useMutation({
onSuccess: () => {
refetchIntegrations();
refetchTrelloUrl();
void refetchIntegrations();
void refetchTrelloUrl();
showPopup({
header: t`Trello 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 (
<>
<PageHead title={t`Settings | Integrations`} />
@@ -112,6 +181,51 @@ export default function IntegrationsSettings() {
)}
</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 */}
<Modal
modalSize="md"

View File

@@ -0,0 +1,78 @@
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { DeleteWebhookConfirmation } from "./components/DeleteWebhookConfirmation";
import { NewWebhookModal } from "./components/NewWebhookModal";
import WebhookList from "./components/WebhookList";
export default function WebhookSettings() {
const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace();
if (!workspace) {
return null;
}
return (
<>
<PageHead title={t`Settings | Webhooks`} />
<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`Webhooks`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Configure webhooks to receive notifications when cards are created, updated, moved, or deleted.`}
</p>
<div className="mb-4 flex items-center justify-between">
<Button variant="primary" onClick={() => openModal("NEW_WEBHOOK")}>
{t`Add webhook`}
</Button>
</div>
<WebhookList workspacePublicId={workspace.publicId} />
</div>
{/* Webhook-specific modals */}
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_WEBHOOK"}
>
<NewWebhookModal workspacePublicId={workspace.publicId} />
</Modal>
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "EDIT_WEBHOOK"}
>
<NewWebhookModal workspacePublicId={workspace.publicId} isEdit />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_WEBHOOK"}
>
<DeleteWebhookConfirmation workspacePublicId={workspace.publicId} />
</Modal>
{/* Global modals */}
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
</>
);
}

View File

@@ -21,6 +21,7 @@ import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescript
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
export default function WorkspaceSettings() {
@@ -86,6 +87,15 @@ export default function WorkspaceSettings() {
disabled={!canEditWorkspace}
/>
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Week start day`}
</h2>
<UpdateWeekStartDayForm
workspacePublicId={workspace.publicId}
weekStartDay={workspaceData?.weekStartDay ?? 1}
disabled={!canEditWorkspace}
/>
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Email visibility`}
</h2>

View File

@@ -0,0 +1,78 @@
import { t } from "@lingui/core/macro";
import { HiXMark } from "react-icons/hi2";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
interface DeleteWebhookConfirmationProps {
workspacePublicId: string;
}
export function DeleteWebhookConfirmation({
workspacePublicId,
}: DeleteWebhookConfirmationProps) {
const { closeModal, entityId: webhookPublicId, entityLabel: webhookName } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const deleteWebhookMutation = api.webhook.delete.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook deleted successfully`, type: "success" });
closeModal();
},
onError: (error) => {
showPopup({
message: error.message || t`Failed to delete webhook`,
type: "error",
});
},
});
const handleDelete = () => {
if (!webhookPublicId) return;
deleteWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
});
};
return (
<div>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-bold">{t`Delete webhook`}</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<p className="text-sm text-neutral-500 dark:text-dark-900">
{t`Are you sure you want to delete the webhook "${webhookName}"? This action cannot be undone.`}
</p>
</div>
<div className="mt-8 flex items-center justify-end gap-3 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Button variant="secondary" onClick={() => closeModal()}>
{t`Cancel`}
</Button>
<Button
variant="danger"
onClick={handleDelete}
isLoading={deleteWebhookMutation.isPending}
>
{t`Delete`}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,322 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { z } from "zod";
import { webhookEvents } from "@kan/db/schema";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const newWebhookSchema = z.object({
name: z
.string()
.min(1, { message: t`Webhook name is required` })
.max(255, { message: t`Webhook name cannot exceed 255 characters` }),
url: z
.string()
.min(1, { message: t`Webhook URL is required` })
.url({ message: t`Please enter a valid URL` })
.max(2048, { message: t`URL cannot exceed 2048 characters` }),
secret: z
.string()
.max(512, { message: t`Secret cannot exceed 512 characters` })
.optional(),
events: z
.array(z.enum(webhookEvents))
.min(1, { message: t`Select at least one event` }),
active: z.boolean(),
});
type WebhookFormData = z.infer<typeof newWebhookSchema>;
interface NewWebhookModalProps {
workspacePublicId: string;
isEdit?: boolean;
}
export function NewWebhookModal({
workspacePublicId,
isEdit = false,
}: NewWebhookModalProps) {
const { closeModal, entityId: webhookPublicId, getModalState, clearModalState } = useModal();
const { showPopup } = usePopup();
const [isTestingWebhook, setIsTestingWebhook] = useState(false);
const modalState = isEdit ? getModalState("EDIT_WEBHOOK") : null;
const utils = api.useUtils();
const {
register,
handleSubmit,
control,
reset,
formState: { errors },
} = useForm<WebhookFormData>({
resolver: zodResolver(newWebhookSchema),
defaultValues: {
name: "",
url: "",
secret: "",
events: [...webhookEvents],
active: true,
},
});
useEffect(() => {
if (isEdit && webhookPublicId && modalState) {
reset({
name: modalState.name ?? "",
url: modalState.url ?? "",
secret: "",
events: modalState.events ?? ["card.created"],
active: modalState.active ?? true,
});
} else if (!isEdit) {
reset({
name: "",
url: "",
secret: "",
events: [...webhookEvents],
active: true,
});
}
}, [isEdit, webhookPublicId, modalState, reset]);
// Clear modal state when closing
useEffect(() => {
return () => {
if (isEdit) {
clearModalState("EDIT_WEBHOOK");
}
};
}, [isEdit, clearModalState]);
const createWebhookMutation = api.webhook.create.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook created successfully`, type: "success" });
closeModal();
},
onError: (error) => {
showPopup({
message: error.message || t`Failed to create webhook`,
type: "error",
});
},
});
const updateWebhookMutation = api.webhook.update.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook updated successfully`, type: "success" });
closeModal();
},
onError: (error) => {
showPopup({
message: error.message || t`Failed to update webhook`,
type: "error",
});
},
});
const testWebhookMutation = api.webhook.test.useMutation({
onSuccess: (result) => {
if (result.success) {
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
} else {
showPopup({
message: result.error || t`Webhook test failed`,
type: "error",
});
}
setIsTestingWebhook(false);
},
onError: (error) => {
showPopup({
message: error.message || t`Failed to test webhook`,
type: "error",
});
setIsTestingWebhook(false);
},
});
const onSubmit = (data: WebhookFormData) => {
if (isEdit && webhookPublicId) {
updateWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
name: data.name,
url: data.url,
secret: data.secret || undefined,
events: data.events,
active: data.active,
});
} else {
createWebhookMutation.mutate({
workspacePublicId,
name: data.name,
url: data.url,
secret: data.secret || undefined,
events: data.events,
});
}
};
const handleTestWebhook = () => {
if (!webhookPublicId) return;
setIsTestingWebhook(true);
testWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
});
};
const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending;
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-bold">
{isEdit ? t`Edit webhook` : t`New webhook`}
</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`Name`}
</label>
<Input
id="name"
placeholder={t`My webhook`}
{...register("name")}
errorMessage={errors.name?.message}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`URL`}
</label>
<Input
id="url"
placeholder="https://example.com/webhook"
{...register("url")}
errorMessage={errors.url?.message}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`Secret (optional)`}
</label>
<Input
id="secret"
type="password"
placeholder={isEdit ? t`Enter new secret to update` : t`HMAC secret for signature verification`}
{...register("secret")}
errorMessage={errors.secret?.message}
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-dark-800">
{t`Used to sign webhook payloads for verification. Leave blank to keep existing secret.`}
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`Events`}
</label>
<Controller
name="events"
control={control}
render={({ field }) => (
<div className="space-y-2">
{webhookEvents.map((event) => (
<label
key={event}
className="flex items-center space-x-2 cursor-pointer"
>
<input
type="checkbox"
checked={field.value.includes(event)}
onChange={(e) => {
if (e.target.checked) {
field.onChange([...field.value, event]);
} else {
field.onChange(
field.value.filter((v) => v !== event)
);
}
}}
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
/>
<span className="text-sm text-light-900 dark:text-dark-900">
{event}
</span>
</label>
))}
</div>
)}
/>
{errors.events && (
<p className="mt-1 text-xs text-red-500">{errors.events.message}</p>
)}
</div>
{isEdit && (
<div>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
{...register("active")}
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
/>
<span className="text-sm text-light-900 dark:text-dark-900">
{t`Active`}
</span>
</label>
</div>
)}
</div>
</div>
<div className="mt-8 flex items-center justify-between border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
{isEdit && webhookPublicId && (
<Button
type="button"
variant="secondary"
onClick={handleTestWebhook}
isLoading={isTestingWebhook}
>
{t`Send test`}
</Button>
)}
</div>
<div>
<Button type="submit" isLoading={isPending}>
{isEdit ? t`Save changes` : t`Create webhook`}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,59 @@
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { api } from "~/utils/api";
export default function UpdateWeekStartDayForm({
workspacePublicId,
weekStartDay,
disabled = false,
}: {
workspacePublicId: string;
weekStartDay: number;
disabled?: boolean;
}) {
const utils = api.useUtils();
const [value, setValue] = useState(weekStartDay);
useEffect(() => {
setValue(weekStartDay);
}, [weekStartDay]);
const updateWorkspace = api.workspace.update.useMutation({
onSuccess: () => {
if (workspacePublicId && workspacePublicId.length >= 12) {
void utils.workspace.byId.invalidate({
workspacePublicId,
});
void utils.workspace.all.invalidate();
}
},
});
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
if (disabled) return;
const newValue = Number(e.target.value);
setValue(newValue);
updateWorkspace.mutate({
workspacePublicId,
weekStartDay: newValue,
});
};
return (
<div className="flex gap-2">
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
<select
value={value}
onChange={handleChange}
disabled={disabled || updateWorkspace.isPending}
className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6"
>
<option value={0}>{t`Sunday`}</option>
<option value={1}>{t`Monday`}</option>
<option value={6}>{t`Saturday`}</option>
</select>
</div>
</div>
);
}

View File

@@ -33,7 +33,7 @@ const UpdateWorkspaceUrlForm = ({
.min(3, {
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-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`,
}),

View File

@@ -0,0 +1,295 @@
import { t } from "@lingui/core/macro";
import type { Locale as DateFnsLocale } from "date-fns";
import { format } from "date-fns";
import { HiEllipsisHorizontal } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import Dropdown from "~/components/Dropdown";
import { useLocalisation } from "~/hooks/useLocalisation";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
interface TableRowProps {
publicId?: string;
name?: string;
url?: string;
events?: string[];
active?: boolean;
createdAt?: Date | null;
dateLocale?: DateFnsLocale;
isLastRow?: boolean;
showSkeleton?: boolean;
onEdit?: () => void;
onTest?: () => void;
onDelete?: () => void;
}
function formatEvents(events: string[]) {
return events
.map((e) => e.replace("card.", ""))
.join(", ");
}
function formatDate(date?: Date | null, locale?: DateFnsLocale) {
if (!date) return "Never";
return format(date, "MMM d, yyyy", { locale });
}
function TableRow({
publicId,
name,
url,
events,
active,
createdAt,
dateLocale,
isLastRow,
showSkeleton,
onEdit,
onTest,
onDelete,
}: TableRowProps) {
return (
<tr className="rounded-b-lg">
<td className={twMerge("w-[25%]", isLastRow ? "rounded-bl-lg" : "")}>
<div className="flex items-center p-4">
<div className="ml-2 min-w-0 flex-1">
<div className="flex items-center">
<p
className={twMerge(
"mr-2 text-sm font-medium text-light-900 dark:text-dark-900",
showSkeleton &&
"mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{name}
</p>
</div>
</div>
</div>
</td>
<td className="w-[30%] px-3 py-4">
<p
className={twMerge(
"truncate text-sm text-light-900 dark:text-dark-900",
showSkeleton &&
"h-3 w-[180px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
title={url}
>
{url}
</p>
</td>
<td className="w-[20%] px-3 py-4">
<p
className={twMerge(
"text-sm text-light-900 dark:text-dark-900",
showSkeleton &&
"h-3 w-[100px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{events && formatEvents(events)}
</p>
</td>
<td className="w-[10%] px-3 py-4">
<span
className={twMerge(
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-medium ring-1 ring-inset",
active
? "bg-emerald-500/10 text-emerald-400 ring-emerald-500/20"
: "bg-gray-500/10 text-gray-400 ring-gray-500/20",
showSkeleton &&
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
)}
>
{!showSkeleton && (active ? t`Active` : t`Inactive`)}
</span>
</td>
<td className="w-[10%] px-3 py-4">
<p
className={twMerge(
"text-sm text-light-900 dark:text-dark-900",
showSkeleton &&
"h-3 w-[80px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{formatDate(createdAt, dateLocale)}
</p>
</td>
<td
className={twMerge(
"w-[5%] min-w-[50px]",
isLastRow && "rounded-br-lg",
)}
>
{!showSkeleton && (
<div className="flex w-full items-center justify-center px-3">
<div className="relative z-50">
<Dropdown
items={[
{
label: t`Edit`,
action: () => onEdit?.(),
},
{
label: t`Test`,
action: () => onTest?.(),
},
{
label: t`Delete`,
action: () => onDelete?.(),
},
]}
>
<HiEllipsisHorizontal
size={25}
className="text-light-900 dark:text-dark-900"
/>
</Dropdown>
</div>
</div>
)}
</td>
</tr>
);
}
interface WebhookListProps {
workspacePublicId: string;
}
export default function WebhookList({ workspacePublicId }: WebhookListProps) {
const { openModal, setModalState } = useModal();
const { showPopup } = usePopup();
const { dateLocale } = useLocalisation();
const { data: webhooks, isLoading } = api.webhook.list.useQuery({
workspacePublicId,
});
const testWebhookMutation = api.webhook.test.useMutation({
onSuccess: (result) => {
if (result.success) {
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
} else {
showPopup({
message: result.error || t`Webhook test failed`,
type: "error",
});
}
},
onError: (error) => {
showPopup({
message: error.message || t`Failed to test webhook`,
type: "error",
});
},
});
if (!isLoading && (!webhooks || webhooks.length === 0)) {
return (
<div className="rounded-lg border border-light-300 bg-light-50 p-8 text-center dark:border-dark-300 dark:bg-dark-100">
<p className="text-sm text-neutral-500 dark:text-dark-900">
{t`No webhooks configured. Add a webhook to receive notifications.`}
</p>
</div>
);
}
return (
<div className="mt-8 flow-root">
<div className="overflow-x-auto overflow-y-visible">
<div className="inline-block min-w-full py-2 pb-12 align-middle">
<div className="relative h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table className="min-w-[700px] divide-y divide-light-600 dark:divide-dark-600">
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
<tr>
<th
scope="col"
className="w-[25%] rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
>
{t`Name`}
</th>
<th
scope="col"
className="w-[30%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
{t`URL`}
</th>
<th
scope="col"
className="w-[20%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
{t`Events`}
</th>
<th
scope="col"
className="w-[10%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
{t`Status`}
</th>
<th
scope="col"
className="w-[10%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
{t`Created`}
</th>
<th
scope="col"
className="w-[5%] rounded-tr-lg px-3 py-3.5 text-center text-sm font-semibold text-light-900 dark:text-dark-900"
>
{/* Actions column */}
</th>
</tr>
</thead>
<tbody className="divide-y divide-light-600 bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
{!isLoading &&
webhooks?.map((webhook, index) => (
<TableRow
key={webhook.publicId}
publicId={webhook.publicId}
name={webhook.name}
url={webhook.url}
events={webhook.events}
active={webhook.active}
createdAt={webhook.createdAt}
dateLocale={dateLocale}
isLastRow={index === webhooks.length - 1}
onEdit={() => {
setModalState("EDIT_WEBHOOK", {
publicId: webhook.publicId,
name: webhook.name,
url: webhook.url,
events: webhook.events,
active: webhook.active,
});
openModal("EDIT_WEBHOOK", webhook.publicId, webhook.name);
}}
onTest={() => {
testWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhook.publicId,
});
}}
onDelete={() => {
openModal("DELETE_WEBHOOK", webhook.publicId, webhook.name);
}}
/>
))}
{isLoading && (
<>
<TableRow showSkeleton />
<TableRow showSkeleton />
<TableRow showSkeleton isLastRow />
</>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,17 @@
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:
image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web}
@@ -9,6 +22,7 @@ services:
build:
context: ..
dockerfile: apps/web/Dockerfile
target: web
args:
APP_VERSION: ${APP_VERSION:-}
env_file:
@@ -23,6 +37,9 @@ services:
- NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT}
- REDIS_URL=${REDIS_URL}
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
- LOG_LEVEL=${LOG_LEVEL}
# Stripe
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
@@ -52,6 +69,7 @@ services:
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
@@ -79,6 +97,9 @@ services:
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
depends_on:
migrator:
condition: service_completed_successfully
networks:
dokploy-network:

View File

@@ -1,4 +1,20 @@
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:
image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web}
@@ -6,10 +22,10 @@ services:
- "${WEB_PORT:-3000}:3000"
networks:
- kan-network
- dokploy-network
build:
context: .
dockerfile: ./apps/web/Dockerfile.new
dockerfile: ./apps/web/Dockerfile
target: web
env_file:
- .env
environment:
@@ -21,6 +37,9 @@ services:
# Redis (optional - for rate limiting)
- REDIS_URL=${REDIS_URL}
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
- LOG_LEVEL=${LOG_LEVEL}
# Admin API key (optional)
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
@@ -41,6 +60,7 @@ services:
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
@@ -107,7 +127,8 @@ services:
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
depends_on:
- postgres
migrate:
condition: service_completed_successfully
restart: unless-stopped
postgres:
@@ -121,14 +142,17 @@ services:
- 5432:5432
volumes:
- 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
networks:
- kan-network
networks:
kan-network:
dokploy-network:
external: true
volumes:
kan_postgres_data:

View File

@@ -0,0 +1,75 @@
import { PGlite } from "@electric-sql/pglite";
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm";
import { drizzle } from "drizzle-orm/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
import * as schema from "@kan/db/schema";
export type TestDbClient = NodePgDatabase<typeof schema> & {
$client: Pool;
};
/**
* Creates a fresh in-memory PGlite database for testing.
* Each call returns an isolated database instance with migrations applied.
*/
export async function createTestDb(): Promise<TestDbClient> {
const client = new PGlite({
extensions: { uuid_ossp, pg_trgm },
});
const db = drizzle(client, { schema });
// Run migrations
await migrate(db, { migrationsFolder: "../../packages/db/migrations" });
return db as unknown as TestDbClient;
}
/**
* Seeds a test database with a workspace and user for testing.
* Returns the created entities for use in tests.
*/
export async function seedTestData(db: TestDbClient) {
// Create a test user
const [user] = await db
.insert(schema.users)
.values({
id: crypto.randomUUID(),
name: "Test User",
email: "test@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
// Create a test workspace (publicId must be exactly 12 chars)
const [workspace] = await db
.insert(schema.workspaces)
.values({
publicId: "wstest123456",
name: "Test Workspace",
slug: "test-workspace",
ownerId: user!.id,
createdAt: new Date(),
})
.returning();
// Add user as admin member of workspace
await db.insert(schema.workspaceMembers).values({
publicId: "wm1234567890",
email: user!.email,
workspaceId: workspace!.id,
userId: user!.id,
createdBy: user!.id,
role: "admin",
status: "active",
createdAt: new Date(),
});
return { user: user!, workspace: workspace! };
}

View File

@@ -0,0 +1,242 @@
import { describe, it, expect, beforeEach } from "vitest";
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import { createTestDb, seedTestData, type TestDbClient } from "./test-db";
describe("webhook repository integration tests", () => {
let db: TestDbClient;
let testUser: { id: string; name: string | null };
let testWorkspace: { id: number; publicId: string };
beforeEach(async () => {
db = await createTestDb();
const seeded = await seedTestData(db);
testUser = seeded.user;
testWorkspace = seeded.workspace;
});
describe("create", () => {
it("creates a webhook with all fields", async () => {
const webhook = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "My Webhook",
url: "https://example.com/webhook",
secret: "my-secret",
events: ["card.created", "card.updated"],
createdBy: testUser.id,
});
expect(webhook).not.toBeNull();
expect(webhook!.name).toBe("My Webhook");
expect(webhook!.url).toBe("https://example.com/webhook");
expect(webhook!.events).toEqual(["card.created", "card.updated"]);
expect(webhook!.active).toBe(true);
expect(webhook!.publicId).toMatch(/^[a-zA-Z0-9]{12}$/);
});
it("creates a webhook without secret", async () => {
const webhook = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "No Secret Webhook",
url: "https://example.com/webhook",
events: ["card.deleted"],
createdBy: testUser.id,
});
expect(webhook).not.toBeNull();
expect(webhook!.name).toBe("No Secret Webhook");
});
});
describe("getByPublicId", () => {
it("retrieves a webhook by public ID", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
expect(retrieved).not.toBeNull();
expect(retrieved!.publicId).toBe(created!.publicId);
expect(retrieved!.name).toBe("Test Webhook");
});
it("returns null for non-existent public ID", async () => {
const retrieved = await webhookRepo.getByPublicId(db, "nonexistent12");
expect(retrieved).toBeNull();
});
});
describe("getAllByWorkspaceId", () => {
it("returns all webhooks for a workspace", async () => {
await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Webhook 1",
url: "https://example.com/webhook1",
events: ["card.created"],
createdBy: testUser.id,
});
await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Webhook 2",
url: "https://example.com/webhook2",
events: ["card.updated"],
createdBy: testUser.id,
});
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
expect(webhooks).toHaveLength(2);
expect(webhooks.map((w) => w.name)).toContain("Webhook 1");
expect(webhooks.map((w) => w.name)).toContain("Webhook 2");
});
it("returns empty array for workspace with no webhooks", async () => {
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
expect(webhooks).toEqual([]);
});
});
describe("getActiveByWorkspaceId", () => {
it("returns only active webhooks", async () => {
const active = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Active Webhook",
url: "https://example.com/active",
events: ["card.created"],
createdBy: testUser.id,
});
const inactive = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Inactive Webhook",
url: "https://example.com/inactive",
events: ["card.created"],
createdBy: testUser.id,
});
// Deactivate one webhook
await webhookRepo.update(db, inactive!.publicId, { active: false });
const activeWebhooks = await webhookRepo.getActiveByWorkspaceId(db, testWorkspace.id);
expect(activeWebhooks).toHaveLength(1);
// getActiveByWorkspaceId returns only publicId, url, secret, events
expect(activeWebhooks[0]!.url).toBe("https://example.com/active");
expect(activeWebhooks[0]!.publicId).toBe(active!.publicId);
});
});
describe("update", () => {
it("updates webhook name", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Original Name",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
const updated = await webhookRepo.update(db, created!.publicId, {
name: "Updated Name",
});
expect(updated).not.toBeNull();
expect(updated!.name).toBe("Updated Name");
expect(updated!.url).toBe("https://example.com/webhook"); // Unchanged
});
it("updates webhook events", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
const updated = await webhookRepo.update(db, created!.publicId, {
events: ["card.created", "card.updated", "card.deleted"],
});
expect(updated!.events).toEqual(["card.created", "card.updated", "card.deleted"]);
});
it("updates webhook active status", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
expect(created!.active).toBe(true);
const updated = await webhookRepo.update(db, created!.publicId, {
active: false,
});
expect(updated!.active).toBe(false);
});
it("sets updatedAt timestamp on update", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
// create() doesn't return updatedAt, verify via getByPublicId
const initial = await webhookRepo.getByPublicId(db, created!.publicId);
expect(initial!.updatedAt).toBeNull();
const updated = await webhookRepo.update(db, created!.publicId, {
name: "Updated",
});
expect(updated!.updatedAt).not.toBeNull();
expect(updated!.updatedAt).toBeInstanceOf(Date);
});
it("returns null for non-existent webhook", async () => {
const updated = await webhookRepo.update(db, "nonexistent12", {
name: "Updated",
});
expect(updated).toBeNull();
});
});
describe("hardDelete", () => {
it("deletes a webhook permanently", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "To Be Deleted",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
await webhookRepo.hardDelete(db, created!.publicId);
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
expect(retrieved).toBeNull();
});
it("does not throw for non-existent webhook", async () => {
await expect(
webhookRepo.hardDelete(db, "nonexistent12"),
).resolves.not.toThrow();
});
});
});

View File

@@ -40,12 +40,15 @@
"dev": "tsc",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
"@kan/auth": "workspace:*",
"@kan/db": "workspace:*",
"@kan/email": "workspace:^",
"@kan/logger": "workspace:^",
"@kan/shared": "workspace:^",
"@kan/stripe": "workspace:^",
"@trpc/server": "catalog:",
@@ -60,7 +63,8 @@
"@kan/tsconfig": "workspace:*",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "^3.0.0"
},
"prettier": "@kan/prettier-config"
}

View File

@@ -11,6 +11,7 @@ import { listRouter } from "./routers/list";
import { memberRouter } from "./routers/member";
import { permissionRouter } from "./routers/permission";
import { userRouter } from "./routers/user";
import { webhookRouter } from "./routers/webhook";
import { workspaceRouter } from "./routers/workspace";
import { createTRPCRouter } from "./trpc";
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
import: importRouter,
permission: permissionRouter,
user: userRouter,
webhook: webhookRouter,
workspace: workspaceRouter,
integration: integrationRouter,
});

View File

@@ -34,6 +34,7 @@ export const boardRouter = createTRPCRouter({
z.object({
workspacePublicId: z.string().min(12),
type: z.enum(["regular", "template"]).optional(),
archived: z.boolean().optional(),
}),
)
.output(
@@ -65,7 +66,10 @@ export const boardRouter = createTRPCRouter({
ctx.db,
workspace.id,
userId,
{ type: input.type }
{
type: input.type,
archived: input.archived ?? false,
}
);
return result;
@@ -153,24 +157,24 @@ export const boardRouter = createTRPCRouter({
// 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;
}
...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,
},
};
}),
),
}
const avatarUrl = await generateAvatarUrl(member.user.image);
return {
...member,
user: {
...member.user,
image: avatarUrl,
},
};
}),
),
}
: result.workspace;
// Generate presigned URLs for card member avatars
@@ -218,7 +222,7 @@ export const boardRouter = createTRPCRouter({
workspaceSlug: z
.string()
.min(3)
.max(24)
.max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
boardSlug: z
.string()
@@ -461,7 +465,8 @@ export const boardRouter = createTRPCRouter({
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.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>>>()))
@@ -503,7 +508,7 @@ export const boardRouter = createTRPCRouter({
}
// 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) {
// Only favorite was updated, return success
@@ -530,6 +535,7 @@ export const boardRouter = createTRPCRouter({
slug: input.slug,
boardPublicId: input.boardPublicId,
visibility: input.visibility,
isArchived: input.isArchived,
});
if (!result)

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