Compare commits

...

101 Commits

Author SHA1 Message Date
Henry
6878c5af5a fix: reliably select workspace on login 2026-05-21 15:38:15 +01:00
hjball
edb88525c0 chore: compile translations 2026-05-18 22:21:55 +00:00
hjball
d69051611c chore: update translations 2026-05-18 22:21:52 +00:00
Henry
cf566e04c6 feat(cloud): improved upgrade journey 2026-05-18 23:20:11 +01:00
Henry
6ea622e7f7 fix(cloud): use prev_licence_key for upgrade/downgrade handler 2026-05-16 23:38:04 +01:00
Danny Halarewich
0e7b7a072c fix(editor): convert pasted markdown to formatted content (#498)
The tiptap-markdown extension was registered with default config, where
`transformPastedText` defaults to false. As a result, pasting markdown
text into card descriptions resulted in literal characters (e.g. '# heading'
shown as a paragraph of text) rather than rendered formatting.

Typing markdown shortcuts in the editor already worked because that goes
through a different path. Programmatic writes via the API also work
because they bypass the editor entirely. Only paste was affected.

Enabling `transformPastedText: true` makes pasted markdown behave as
users intuitively expect, matching the typed-markdown UX.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 23:03:06 +01:00
Nick Meinhold
f801d2a7eb fix(s3): default S3_REGION to us-east-1 instead of empty string (#495)
The AWS SDK throws "Region is missing" when S3Client is constructed
with an empty-string region, so any S3 feature (avatars, attachments,
presigned URL generation) is silently broken when S3_REGION is unset
— even though .env.example ships it unset and env.ts declares it
optional. The schema and the runtime disagreed about whether the var
was required.

Default to "us-east-1" in createS3Client. S3-compatible providers
(MinIO, Backblaze B2, R2, DigitalOcean Spaces, Wasabi) ignore the
region entirely; real AWS S3 users should set S3_REGION explicitly
to their bucket's actual region (clarified in .env.example).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:56:48 +01:00
Nick Meinhold
d9157b6218 fix(workspace): point package "types" exports at src/, not stale dist/ (#496)
Each workspace package had its exports map pointing `types` at
`./dist/...d.ts` while `default` pointed at `./src/...ts`. The dist
directory is a build artifact only refreshed when someone runs
`pnpm build` in that package — meaning every other package in the
monorepo typechecks against last week's type signatures.

Concrete failure mode: contributor edits a function in
packages/shared/src/utils/foo.ts (changes a parameter type, adds a
new export, etc.), runs `pnpm typecheck` from root, sees green. The
check actually validated against the stale dist/.d.ts. Real type
errors stay invisible until CI builds shared first, by which point
the diff is already pushed.

Fix: point `types` at the same `src/` paths the runtime resolves to.
TypeScript reads .ts source as types fine when consumers share the
same TS version, which a monorepo guarantees. Verified end-to-end:
adding a new export to shared and immediately typechecking @kan/api
now picks it up without rebuilding shared, and breaking a return
type immediately fails the consumer's typecheck.

Applied to @kan/api, @kan/db, @kan/logger, @kan/shared.
@kan/email is intentionally left as-is because its source is .tsx
(JSX) — pointing types at .tsx would force every consumer to enable
--jsx in their tsconfig, which is a worse cascade than the stale
dist problem we're solving.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:55:07 +01:00
hjball
711ff7f610 chore: update translations 2026-05-16 21:36:10 +00:00
Henry
742e8a1fab fix(cloud): prevent reprompting users to onboard after first signup 2026-05-16 22:34:59 +01:00
hjball
69ed5fd8f2 chore: compile translations 2026-05-16 21:25:37 +00:00
hjball
e21830138d chore: update translations 2026-05-16 21:25:33 +00:00
Henry
f38533590d feat(cloud): fix upgrade/downgrade and migrate webhook handler 2026-05-16 22:23:59 +01:00
hjball
7d52e4eb88 chore: compile translations 2026-05-15 21:34:55 +00:00
hjball
692123433b chore: update translations 2026-05-15 21:34:51 +00:00
Henry
8436644966 feat(cloud): improve partner onboarding 2026-05-15 22:33:18 +01:00
Henry
0c2b670baf Update README.md 2026-05-15 21:01:42 +01:00
Henry
584fe13244 fix(cloud): allow callback without code 2026-05-14 14:38:34 +01:00
Henry
cc6804c21d fix(cloud): update license key on conflict 2026-05-14 14:02:17 +01:00
Henry
8512946102 fix(cloud): partner webhook integration 2026-05-14 13:42:36 +01:00
Henry
6bebb14855 feat(cloud): add partner config to docker compose 2026-05-14 13:22:06 +01:00
hjball
0adb68d547 chore: update translations 2026-05-13 21:12:51 +00:00
Henry
e26c122d76 fix: add card number fields to zod schemas 2026-05-13 22:11:29 +01:00
hjball
95321c1c0d chore: update translations 2026-05-13 20:41:18 +00:00
Henry
f459c489f9 fix: broken migrations 2026-05-13 21:39:47 +01:00
hjball
460008d340 chore: compile translations 2026-05-12 21:40:38 +00:00
hjball
4371fe2d4d chore: update translations 2026-05-12 21:40:34 +00:00
Henry
60d8e1bd83 feat(cloud): partner integration (#493)
* feat: add partnerLicenseKey and partnerTier to subscription schema

* feat: add repo funcs

* feat: set up partner webhook handler
2026-05-12 22:39:07 +01:00
Henry
4459fbb4b2 fix: pin pnpm version in dockerfile to prevent corepack fetching latest 2026-05-11 15:49:34 +01:00
hjball
507c553778 chore: update translations 2026-05-11 12:45:01 +00:00
Henry
3d9e859b31 refactor: small tweaks to password reset flow 2026-05-11 13:43:38 +01:00
hjball
54dcd08d9a chore: compile translations 2026-05-11 12:42:35 +00:00
hjball
468aeef8b6 chore: update translations 2026-05-11 12:42:31 +00:00
Morfixx
71205b67dc fix: unable to change password when using magic link (#484)
* fix: fixed password not resetting & added password prompt with save guards

* fix: migrated to using setPassword
2026-05-11 13:40:59 +01:00
Midas
607672718c Add cardPublicId to card webhook payloads (#490)
* Add cardPublicId to card webhook payloads

* fix: include card publicId in webhook payloads

Add data.card.publicId to webhook payloads while keeping data.card.id for compatibility, and update card/webhook call sites and tests so webhook consumers can reliably use public IDs.
2026-05-11 13:22:21 +01:00
Henry
dd262ee50f fix: allow reordering without supplying an index when updating cards (#492) 2026-05-11 13:18:07 +01:00
Henry
82c8eef0c2 Update README.md 2026-05-05 21:50:50 +01:00
hjball
ea116268ff chore: update translations 2026-05-05 20:49:45 +00:00
Henry
74384d841e Update README.md 2026-05-05 21:48:29 +01:00
hjball
c0962e9f70 chore: compile translations 2026-04-28 21:31:18 +00:00
hjball
3e6aec2d6b chore: update translations 2026-04-28 21:31:14 +00:00
Florent Azonnoudo
4658b2961c feat: add ticket numbers to cards with workspace prefix (#443)
* feat: add ticket numbers to cards with workspace prefix

- Add cardNumber to cards
- Add cardPrefix/cardCounter to workspace
- Populate initial prefixes and numbers via new migration
- Introduce generateWorkspacePrefix util and export it
- Include cardNumber in card-related API responses and search results
- Render and display tickets as PREFIX-NUMBER in UI
- Update Card component to accept ticketNumber
- Update CardModal and board cards to show number when available
- Add cardNumber to CommandPalette search results type

* feat: regen migration

* feat: enhance card and workspace schemas with cardNumber and indexing

- Updated the card repository to allocate card numbers atomically per workspace.
- Modified the card schema to include a cardNumber field and added an index on listId and cardNumber for improved query performance.
- Enhanced the workspace schema to include an index on cardPrefix for optimized lookups.
- Adjusted the migration journal to reflect the new schema changes and their timestamps.
- Updated the regex in workspace repository to allow alphanumeric prefixes in ticket IDs.

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-04-28 22:29:41 +01:00
hjball
bc8b5463b3 chore: compile translations 2026-04-20 21:37:14 +00:00
hjball
9eee59a877 chore: update translations 2026-04-20 21:37:10 +00:00
Henry
e32e400976 chore: clean up types (#476) 2026-04-20 22:35:39 +01:00
Louie Chillingworth
b4b07f3120 fix: remove duplicate OIDC provider registration in getSocialProviders (#475)
The getSocialProviders endpoint was manually pushing "oidc" to the 
providers array when OIDC env vars are present, but Better Auth already 
includes the OIDC provider in ctx.context.socialProviders when it is 
configured. This resulted in ["oidc","oidc"] being returned from 
/api/auth/social-providers, causing a duplicate provider registration 
that led to a TypeError: Cannot read properties of null (reading 'id') 
during the OIDC login callback, preventing all OIDC logins.

Fix: remove the manual providers.push("oidc") block as it is redundant.
2026-04-20 21:20:23 +01:00
vvaswani
e39faa1172 fix: use stable public ids for card webhook (#463)
Signed-off-by: Vikram Vaswani <2571660+vvaswani@users.noreply.github.com>
2026-04-20 21:19:31 +01:00
hjball
b46979f539 chore: update translations 2026-04-18 20:42:20 +00:00
Nick Meinhold
f4237fffe6 fix: redirect to boards list after archiving/unarchiving a board (#459)
Previously, archiving or unarchiving a board left the user on the
(now archived/unarchived) board view with no clear navigation path.
This redirects to /boards after either action, matching the existing
behaviour of delete and move operations.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 21:41:04 +01:00
Redox
1d9bb96367 Don't default NEXT_PUBLIC_STORAGE_URL to empty string (#455) 2026-04-18 21:33:29 +01:00
hjball
bcb449e394 chore: update translations 2026-04-18 20:30:54 +00:00
Morfixx
ae4bc77407 feat: added schemas for openapi (#452) 2026-04-18 21:29:35 +01:00
hjball
6056167063 chore: update translations 2026-04-18 08:33:16 +00:00
Henry
adf91c7dfd refactor: remove legacy code from NewWorkspaceForm (#471) 2026-04-18 09:31:51 +01:00
hjball
bba9a94e45 chore: update translations 2026-04-17 21:23:27 +00:00
Henry
e36eb64c59 fix(cloud): enable upgrading new and existing workspaces onto team/pro (#470)
* feat(cloud): enable upgrading new and existing workspaces onto team/pro

* refactor: clean up

* fix: toggle spacing
2026-04-17 22:22:14 +01:00
hjball
201fe05493 chore: update translations 2026-04-11 07:19:15 +00:00
Henry
25490b2223 feat(cloud): temporarily disable context menu 2026-04-11 08:17:59 +01:00
hjball
b3108942db chore: compile translations 2026-04-11 07:01:09 +00:00
hjball
2ba759966e chore: update translations 2026-04-11 07:01:05 +00:00
Henry
7045cd8e25 feat(cloud): improved workspace onboarding (#462)
* feat: setup onboarding and select plan page

* feat: add workspace details step

* refactor: optimize board rendering in workspace details

* feat: tweak padding in boards mockup

* fix: validate/convert slug input

* refactor: tweak onboarding steps UX

* feat: add team to workspace plans

* feat: allow setting description when creating a workspace

* feat: add logging to next api routes

* feat: create worspace on checkout.session.completed

* chore: update types

* refactor: improve webhook logs

* feat: create workspace on successful activation

* feat: set slug on workspace name change

* feat: add returnUrl params
2026-04-11 07:59:26 +01:00
hjball
603cc46b7d chore: compile translations 2026-04-01 13:35:04 +00:00
hjball
e9d338384a chore: update translations 2026-04-01 13:34:59 +00:00
Nick Meinhold
8a2a9ecef5 fix: avatar upload display and oversized crop on HiDPI screens (#442)
Two related avatar bugs:

1. getAvatarUrl() returned empty string for S3 keys, so uploaded
   avatars never displayed. Now constructs the full URL using
   NEXT_PUBLIC_STORAGE_URL and NEXT_PUBLIC_AVATAR_BUCKET_NAME,
   with support for both path-style (MinIO) and virtual-hosted
   (Tigris/AWS S3) URLs.

2. Avatar crop scaled canvas by devicePixelRatio (4x pixels on
   Retina), producing blobs that exceeded the 2MB upload limit
   even for small source images. Now caps output at 512x512 and
   uses quality=0.85 for toBlob().

Closes #440, closes #441

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-01 14:33:11 +01:00
Charity
3a44957662 feat: add card context menu and duplication functionality (#381)
* feat: add card context menu and duplication functionality

* Implemented a context menu for cards allowing actions such as copying links, duplicating cards, and managing members, labels, and due dates
* Added modals for card duplication and context actions.
* Updated API with a new endpoint for duplicating cards, including options for copying labels, members, and checklists

* refactor: remove cardPublicId from context menu and related components

* Removed cardPublicId prop from CardContextMenu and CardContextMembersModal for cleaner context handling
* Updated scrollbar styling

* feat: add delete card functionality to context menu
2026-04-01 14:32:56 +01:00
Henry
20e6c99e32 feat: enhance logging with request input (#450) 2026-03-23 22:31:02 +00:00
Henry
c0aae5449f fix: prevent invalid calls to fetch label by publicId (#449)
* fix: prevent invalid calls to fetch label by publicId

* fix: ensure query only runs when isEdit is true
2026-03-23 21:40:35 +00:00
Henry
596f346181 fix: increase maximum length for boardSlug from 24 to 60 characters (#448) 2026-03-23 16:30:29 +00:00
Henry
7b59e2ac07 feat(cloud): log to docker container 2026-03-22 07:21:30 +00:00
Henry
2a9b1fd2b5 refactor: replace @axiomhq/pino with @axiomhq/js 2026-03-20 13:56:52 +00:00
Henry
a2d4314e14 fix: update Dockerfile to include pino transport packages 2026-03-20 13:26:27 +00:00
Henry
f94e4d6da2 fix: include pino transports in outputFileTracingIncludes 2026-03-20 12:04:46 +00:00
Henry
97f4defc0e fix: add pino to serverExternalPackages 2026-03-20 11:46:08 +00:00
Henry
dd29dc89fd fix: remove broken dep 2026-03-19 21:33:44 +00:00
Henry
486cdf8313 feat(cloud): setup log drain (#447) 2026-03-19 21:16:47 +00:00
Henry
67cf523ee8 fix: API key authentication and REST handler errors (#446) 2026-03-19 12:20:19 +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
187 changed files with 37921 additions and 4738 deletions

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
@@ -23,17 +23,19 @@ SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
NEXT_PUBLIC_DISABLE_EMAIL=
# S3 storage (optional)
S3_REGION=
S3_ENDPOINT=
S3_REGION= # required for AWS S3 (e.g. us-east-1); ignored by most S3-compatible providers — defaults to us-east-1 if unset
S3_ENDPOINT= # set for S3-compatible providers (MinIO, R2, Spaces, etc.); leave blank for AWS S3
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)

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

@@ -203,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` |
@@ -212,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.
@@ -225,6 +227,12 @@ We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
</a>
## Sponsors ❤️
[<img height="100" alt="image" src="https://github.com/user-attachments/assets/e331c71f-ac86-46a6-bceb-ce276de094b0" />](https://www.testmuai.com)
Proudly sponsored by [TestMu AI (formerly LambdaTest)](https://www.testmuai.com) - an AI-native testing cloud platform built for modern engineering teams. Covering everything from autonomous test creation and fast execution to testing AI agents like chatbots and voice assistants. If you're serious about testing, go check them out.
## License 📝
Kan is licensed under the [AGPLv3 license](LICENSE).

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

@@ -11,6 +11,7 @@ RUN apk update && \
apk add --no-cache libc6-compat && \
rm -rf /var/cache/apk/* /tmp/* || true && \
corepack enable && \
corepack prepare pnpm@9.14.2 --activate && \
npm install turbo@2.3.1 --global && \
pnpm config set store-dir ~/.pnpm-store

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,7 @@ const config = {
],
},
/** Enables hot reloading for local packages without a build step */
transpilePackages: [
"@kan/api",
@@ -65,6 +66,8 @@ const config = {
},
},
},
serverExternalPackages: ["pino"],
experimental: {
// instrumentationHook: true,
swcPlugins: [["@lingui/swc-plugin", {}]],

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",

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

@@ -46,6 +46,7 @@ interface FormValues {
interface AuthProps {
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
isSignUp?: boolean;
callbackURL?: string;
}
const EmailSchema = z.object({
@@ -152,7 +153,11 @@ const availableSocialProviders = {
},
};
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
export function Auth({
setIsMagicLinkSent,
isSignUp,
callbackURL: callbackURLProp,
}: AuthProps) {
const [isCloudEnv, setIsCloudEnv] = useState(false);
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
useState<null | AuthProvider>(null);
@@ -165,7 +170,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
const passwordRef = useRef<HTMLInputElement | null>(null);
const redirect = useSearchParams().get("next");
const callbackURL = redirect ?? "/boards";
const callbackURL = callbackURLProp ?? redirect ?? "/boards";
// Safely get environment variables on client side to avoid hydration mismatch
useEffect(() => {

View File

@@ -33,6 +33,7 @@ type SearchResult =
boardPublicId: string;
boardName: string;
listName: string;
cardNumber: number | null;
updatedAt: Date | null;
createdAt: Date;
type: "card";
@@ -145,7 +146,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("");
@@ -160,8 +160,17 @@ export default function CommandPallette({
)}
</div>
<div className="min-w-0 flex-1 text-left">
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
{result.title}
<div className="flex items-center gap-2">
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
{result.title}
</div>
{result.type === "card" &&
result.cardNumber != null &&
workspace.cardPrefix && (
<span className="flex-shrink-0 text-xs text-light-600 dark:text-dark-600">
{workspace.cardPrefix}-{result.cardNumber}
</span>
)}
</div>
{result.type === "card" && (
<div className="truncate text-xs text-light-700 dark:text-dark-700">

View File

@@ -1,3 +1,6 @@
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useTheme } from "next-themes";
import { useEffect, useRef, useState } from "react";
import {
@@ -11,8 +14,12 @@ import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
import { api } from "~/utils/api";
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
import Button from "./Button";
import Modal from "./modal";
import SideNavigation from "./SideNavigation";
interface DashboardProps {
@@ -41,8 +48,11 @@ export default function Dashboard({
hasRightPanel = false,
}: DashboardProps) {
const { resolvedTheme } = useTheme();
const { openModal } = useModal();
const { openModal, closeModal, modalContentType } = useModal();
const { availableWorkspaces, hasLoaded } = useWorkspace();
const { showPopup } = usePopup();
const router = useRouter();
const searchParams = useSearchParams();
const { data: session, isPending: sessionLoading } = authClient.useSession();
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
@@ -97,10 +107,65 @@ export default function Dashboard({
});
useEffect(() => {
if (hasLoaded && availableWorkspaces.length === 0) {
openModal("NEW_WORKSPACE", undefined, undefined, false);
const partnerActivated = searchParams.get("partner_activated");
const partnerError = searchParams.get("partner_error");
if (partnerActivated) {
showPopup({
header: t`License activated`,
message: t`Your license has been activated successfully.`,
icon: "success",
});
const params = new URLSearchParams(searchParams.toString());
params.delete("partner_activated");
router.replace(`?${params.toString()}`);
} else if (partnerError) {
const messages: Record<string, string> = {
invalid_license: t`That license key could not be found. Please contact support.`,
license_inactive: t`Your license is not active. Please check your account.`,
missing_license: t`No license key was provided. Please try activating again.`,
};
showPopup({
header: t`License activation failed`,
message:
messages[partnerError] ??
t`Something went wrong during license activation.`,
icon: "error",
});
const params = new URLSearchParams(searchParams.toString());
params.delete("partner_error");
router.replace(`?${params.toString()}`);
}
}, [hasLoaded, availableWorkspaces.length, openModal]);
}, [searchParams, showPopup, router]);
useEffect(() => {
if (hasLoaded && availableWorkspaces.length === 0) {
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
router.push(
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
} else {
openModal("NEW_WORKSPACE", undefined, undefined, false);
}
}
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
useEffect(() => {
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
if (
!userLoading &&
user &&
isCredentialsEnabled &&
user.hasMagicLinkAccount &&
!user.hasPassword &&
!sessionStorage.getItem("set_password_prompted")
) {
sessionStorage.setItem("set_password_prompted", "1");
openModal("SET_PASSWORD_PROMPT");
}
}, [user, userLoading, openModal]);
const isDarkMode = resolvedTheme === "dark";
@@ -196,6 +261,30 @@ export default function Dashboard({
</div>
</div>
</div>
<Modal
modalSize="sm"
isVisible={modalContentType === "SET_PASSWORD_PROMPT"}
>
{user?.hasPassword ? (
<div className="p-5">
<h2 className="pb-4 text-base font-medium dark:text-white">{t`Password already set`}</h2>
<p className="mb-6 text-sm text-light-900">
{t`Your account already has a password. You can change it from your account settings.`}
</p>
<Button
variant="secondary"
onClick={closeModal}
fullWidth
size="lg"
>
{t`Close`}
</Button>
</div>
) : (
<ChangePasswordFormConfirmation hasPassword={false} />
)}
</Modal>
</>
);
}

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

@@ -85,7 +85,7 @@ export interface RenderSuggestionsProps {
export interface WorkspaceMember {
publicId: string;
user: {
id: string;
id: string | null;
name: string | null;
image: string | null;
} | null;
@@ -474,7 +474,7 @@ export default function Editor({
autolink: true,
linkOnPaste: true,
}),
Markdown,
Markdown.configure({ transformPastedText: true }),
Placeholder.configure({
placeholder: readOnly
? ""

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

@@ -88,7 +88,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
</button>
)}
{iconRight && type !== "password" && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="absolute right-3 top-1/2 -translate-y-1/2 leading-[0]">
{iconRight}
</div>
)}

View File

@@ -39,7 +39,7 @@ export function LabelForm({
labelPublicId: entityId,
},
{
enabled: isEdit && !!entityId,
enabled: !!isEdit && entityId.length >= 12,
},
);
@@ -75,7 +75,7 @@ export function LabelForm({
reset(newFormState);
}
} catch (e) {
console.log(e);
console.error(e);
}
},
});
@@ -207,7 +207,7 @@ export function LabelForm({
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{!isEdit && (
<Toggle
label={t`Create another`}
@@ -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

@@ -1,21 +1,14 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import {
HiBolt,
HiCheck,
HiCheckBadge,
HiInformationCircle,
HiXMark,
} from "react-icons/hi2";
import { HiCheck, HiXMark } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useDebounce } from "~/hooks/useDebounce";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -54,7 +47,6 @@ export function NewWorkspaceForm() {
watch,
trigger,
clearErrors,
setValue,
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
@@ -67,16 +59,10 @@ export function NewWorkspaceForm() {
const hasAvailableWorkspaces = availableWorkspaces.length > 0;
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
const slug = watch("slug");
const [debouncedSlug] = useDebounce(slug, 500);
const isTyping = slug !== debouncedSlug;
// Pro toggle state management
const [isProToggleEnabled, setIsProToggleEnabled] = useState(false);
const [lastAvailableSlug, setLastAvailableSlug] = useState<string>("");
// Validate slug only after debounce
useEffect(() => {
if (isTyping) {
@@ -101,7 +87,7 @@ export function NewWorkspaceForm() {
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
const createWorkspace = api.workspace.create.useMutation({
onSuccess: async (values, variables) => {
onSuccess: (values) => {
if (values.publicId && values.name) {
void utils.workspace.all.invalidate();
switchWorkspace({
@@ -110,45 +96,11 @@ export function NewWorkspaceForm() {
description: values.description,
slug: values.slug,
plan: values.plan,
cardPrefix: values.cardPrefix,
role: "admin",
weekStartDay: 1,
});
// If in cloud and Pro toggle is enabled, create checkout session for pro
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isProToggleEnabled) {
try {
const response = await fetch(
"/api/stripe/create_checkout_session",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
slug: slug || undefined,
workspacePublicId: values.publicId,
cancelUrl: "/settings/workspace?upgrade=pro",
successUrl: "/boards",
}),
},
);
const data = await response.json();
const url = (data as { url: string }).url;
if (url) {
window.location.href = url;
return; // Don't close modal if redirecting to checkout
}
} catch (error) {
console.error("Error creating checkout session:", error);
showPopup({
header: t`Error upgrading to Pro`,
message: t`Workspace created successfully. You can upgrade later in settings.`,
icon: "warning",
});
}
}
closeModal();
}
},
@@ -167,57 +119,8 @@ export function NewWorkspaceForm() {
if (nameElement) nameElement.focus();
}, []);
const [shouldShowBenefits, setShouldShowBenefits] = useState(false);
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
useEffect(() => {
if (
isCloudEnv &&
!checkWorkspaceSlugAvailability.isPending &&
isValidSlug
) {
const isAvailable = isWorkspaceSlugAvailable?.isAvailable === true;
setShouldShowBenefits(isAvailable);
// Automatically enable Pro toggle when slug is available
if (isAvailable) {
setIsProToggleEnabled(true);
setLastAvailableSlug(slug);
}
}
}, [
isValidSlug,
isWorkspaceSlugAvailable?.isAvailable,
checkWorkspaceSlugAvailability.isPending,
slug,
isCloudEnv,
]);
// Reset benefits when slug becomes invalid
useEffect(() => {
if (!isValidSlug) {
setShouldShowBenefits(false);
}
}, [isValidSlug]);
// Handle Pro toggle changes
const handleProToggleChange = () => {
if (isProToggleEnabled) {
// Turning off: clear the slug and disable Pro
setValue("slug", "");
setIsProToggleEnabled(false);
} else {
// Turning on: restore the last available slug if we have one
if (lastAvailableSlug) {
setValue("slug", lastAvailableSlug);
}
setIsProToggleEnabled(true);
}
};
const showProBenefits = isProToggleEnabled;
const onSubmit = (values: FormValues) => {
// Don't submit if slug is provided but not available
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
@@ -226,7 +129,7 @@ export function NewWorkspaceForm() {
createWorkspace.mutate({
name: values.name,
slug: !isCloudEnv && values.slug ? values.slug : undefined,
slug: values.slug,
});
};
@@ -289,11 +192,7 @@ export function NewWorkspaceForm() {
? t`This workspace URL is reserved`
: undefined)
}
prefix={
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? "kan.bn/"
: `${env("NEXT_PUBLIC_BASE_URL")}/`
}
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/`}
iconRight={
slug && slug.length >= 3 && !errors.slug ? (
isWorkspaceSlugAvailable?.isAvailable ? (
@@ -310,68 +209,9 @@ export function NewWorkspaceForm() {
}
}}
/>
{slug && slug.length >= 3 && shouldShowBenefits && (
<div className="mt-2 flex items-center gap-1">
<HiInformationCircle className="h-4 w-4 text-dark-900" />
<p className="text-xs text-gray-500 dark:text-dark-900">
{t`Custom URLs require upgrading to a Pro plan`}
</p>
</div>
)}
</div>
{showProBenefits && (
<div className="mt-6">
<div className="rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
<div className="space-y-3">
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-900 dark:text-dark-1000">
{t`Unlimited members`}
</span>
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400 sm:text-[10px]">
{t`Launch offer`}
</span>
</div>
</div>
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
<span className="text-xs text-neutral-900 dark:text-dark-1000">
{t`Custom workspace URL`}
</span>
</div>
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-900 dark:text-dark-1000">
{t`Board analytics`}
</span>
<span className="inline-flex items-center rounded-full bg-gray-500/10 px-2 py-0.5 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/20 dark:text-gray-400 sm:text-[10px]">
{t`Coming soon`}
</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
<div
className={twMerge(
"mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600",
!showProBenefits && "mt-12",
)}
>
{/* Pro Toggle - only show in cloud environment */}
{isCloudEnv && (
<Toggle
isChecked={isProToggleEnabled}
onChange={handleProToggleChange}
label={t`Upgrade to Pro ($29/month)`}
/>
)}
<div className="mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button
type="submit"

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

@@ -28,7 +28,6 @@ import ButtonComponent from "~/components/Button";
import ReactiveButton from "~/components/ReactiveButton";
import UserMenu from "~/components/UserMenu";
import WorkspaceMenu from "~/components/WorkspaceMenu";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
@@ -53,7 +52,6 @@ export default function SideNavigation({
const { workspace } = useWorkspace();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isInitialised, setIsInitialised] = useState(false);
const { openModal } = useModal();
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
@@ -223,21 +221,19 @@ export default function SideNavigation({
<ButtonComponent
iconLeft={<HiBolt />}
variant="secondary"
href="/settings/workspace?upgrade=pro"
aria-label="Upgrade to Pro"
title="Upgrade to Pro"
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
aria-label={t`Start free trial`}
title={t`Start free trial`}
iconOnly
onClick={() => openModal("UPGRADE_TO_PRO")}
/>
) : (
<ButtonComponent
iconLeft={<HiBolt />}
fullWidth
variant="secondary"
href="/settings/workspace?upgrade=pro"
onClick={() => openModal("UPGRADE_TO_PRO")}
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
>
{t`Upgrade to Pro`}
{t`Start free trial`}
</ButtonComponent>
)}
</div>

View File

@@ -7,15 +7,17 @@ const Toggle = ({
label,
disabled,
showLabel = true,
labelPosition = "before",
}: {
isChecked: boolean;
onChange: () => void;
label: string;
disabled?: boolean;
showLabel?: boolean;
labelPosition?: "before" | "after";
}) => (
<div className="mr-4 flex items-center justify-end">
{showLabel && (
<div className="flex items-center">
{showLabel && labelPosition === "before" && (
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
@@ -38,6 +40,11 @@ const Toggle = ({
)}
/>
</Switch>
{showLabel && labelPosition === "after" && (
<span className="ml-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
)}
</div>
);

View File

@@ -1,5 +1,7 @@
import { Button, Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { Fragment, useState } from "react";
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
@@ -18,6 +20,7 @@ export default function WorkspaceMenu({
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace();
const { openModal } = useModal();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const { tooltipContent: commandPaletteShortcutTooltipContent } =
@@ -147,7 +150,11 @@ export default function WorkspaceMenu({
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
<Menu.Item>
<button
onClick={() => openModal("NEW_WORKSPACE")}
onClick={() =>
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`)
: openModal("NEW_WORKSPACE")
}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
{t`Create workspace`}

View File

@@ -1,6 +1,6 @@
import type { Locale as DateFnsLocale } from "date-fns";
import { useLingui } from "@lingui/react";
import { de, enGB, es, fr, it, nl, pl, ru, ptBR} from "date-fns/locale";
import { de, enGB, es, fr, it, nl, pl, ptBR, ru } from "date-fns/locale";
import type { Locale } from "~/locales";
import { useLinguiContext } from "~/providers/lingui";
@@ -19,7 +19,7 @@ export function useLocalisation() {
nl,
ru,
pl,
"pt-BR": ptBR,
ptbr: ptBR,
};
const currentDateLocale = dateLocaleMap[locale] ?? enGB;

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 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 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 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 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 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

@@ -1,49 +1,75 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { withApiLogging } from "@kan/api/utils/apiLogging";
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",
});
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
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) {
return res.status(500).json({ message: "Failed to download attachment" });
}
}),
);

View File

@@ -1,25 +1,25 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
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" });
}
try {
const response = await fetch("https://formbricks.com/api/oss-friends");
if (!response.ok) {
throw new Error("Failed to fetch from Formbricks");
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const data = await response.json();
return res.status(200).json(data);
} catch (error) {
console.error("Error fetching OSS friends:", error);
return res.status(500).json({ message: "Failed to fetch OSS friends" });
}
},
try {
const response = await fetch("https://formbricks.com/api/oss-friends");
if (!response.ok) {
throw new Error("Failed to fetch from Formbricks");
}
const data = await response.json();
return res.status(200).json(data);
} catch (error) {
return res.status(500).json({ message: "Failed to fetch OSS friends" });
}
}),
);

View File

@@ -0,0 +1,19 @@
export type WorkspacePlan = "free" | "team" | "pro" | "enterprise";
export interface TierConfig {
plan: WorkspacePlan;
seats: number | null;
unlimitedSeats: boolean;
}
const TIER_MAP: Record<number, TierConfig> = {
1: { plan: "team", seats: 5, unlimitedSeats: false },
2: { plan: "pro", seats: 15, unlimitedSeats: false },
3: { plan: "pro", seats: null, unlimitedSeats: true },
};
export function tierConfig(tier: number): TierConfig {
const config = TIER_MAP[tier];
if (!config) throw new Error(`Unknown partner tier: ${tier}`);
return config;
}

View File

@@ -0,0 +1,168 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { tierConfig } from "./_utils";
const log = createLogger("api");
interface TokenResponse {
access_token: string;
token_type: string;
}
interface OAuthLicenseResponse {
license_key: string;
status: string;
}
interface LicenseDetailResponse {
license_key: string;
status: string;
tier: number;
}
async function exchangeCodeForToken(code: string): Promise<TokenResponse> {
const tokenUrl = process.env.PARTNER_TOKEN_URL;
const clientId = process.env.PARTNER_CLIENT_ID;
const clientSecret = process.env.PARTNER_CLIENT_SECRET;
const redirectUrl = process.env.PARTNER_REDIRECT_URL;
if (!tokenUrl) throw new Error("PARTNER_TOKEN_URL not configured");
if (!clientId) throw new Error("PARTNER_CLIENT_ID not configured");
if (!clientSecret) throw new Error("PARTNER_CLIENT_SECRET not configured");
if (!redirectUrl) throw new Error("PARTNER_REDIRECT_URL not configured");
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUrl,
}),
});
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
return res.json() as Promise<TokenResponse>;
}
async function fetchOAuthLicense(
accessToken: string,
): Promise<OAuthLicenseResponse> {
const oauthLicenseUrl = process.env.PARTNER_OAUTH_LICENSE_URL;
if (!oauthLicenseUrl)
throw new Error("PARTNER_OAUTH_LICENSE_URL not configured");
const res = await fetch(`${oauthLicenseUrl}?access_token=${accessToken}`);
if (!res.ok) throw new Error(`OAuth license fetch failed: ${res.status}`);
return res.json() as Promise<OAuthLicenseResponse>;
}
async function fetchLicenseDetail(
licenseKey: string,
): Promise<LicenseDetailResponse> {
const licenseApiUrl = process.env.PARTNER_LICENSE_API_URL;
const apiKey = process.env.PARTNER_API_KEY;
if (!licenseApiUrl) throw new Error("PARTNER_LICENSE_API_URL not configured");
if (!apiKey) throw new Error("PARTNER_API_KEY not configured");
const apiKeyHeader = process.env.PARTNER_API_KEY_HEADER;
if (!apiKeyHeader) throw new Error("PARTNER_API_KEY_HEADER not configured");
const res = await fetch(`${licenseApiUrl}/${licenseKey}`, {
headers: { [apiKeyHeader]: apiKey },
});
if (!res.ok) throw new Error(`License detail fetch failed: ${res.status}`);
return res.json() as Promise<LicenseDetailResponse>;
}
export default withRateLimit(
{ points: 20, duration: 60 },
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const { code } = req.query;
if (!code || typeof code !== "string") {
return res.status(200).json({ message: "OK" });
}
let license: LicenseDetailResponse;
try {
const tokenData = await exchangeCodeForToken(code);
const oauthLicense = await fetchOAuthLicense(tokenData.access_token);
license = await fetchLicenseDetail(oauthLicense.license_key);
} catch (err) {
log.error({ err }, "Partner OAuth flow failed");
return res.redirect(`/partner/activate?error=oauth_failed`);
}
const { db, user } = await createNextApiContext(req);
const cfg = tierConfig(license.tier);
const isActive = license.status === "active";
const status = isActive ? "active" : "inactive";
if (!user) {
await subscriptionRepo.upsertByPartnerLicenseKey(
db,
license.license_key,
{
plan: cfg.plan,
status,
partnerTier: license.tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
);
return res.redirect(
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
);
}
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
const workspace = memberships?.[0]?.workspace;
if (!workspace) {
await subscriptionRepo.upsertByPartnerLicenseKey(
db,
license.license_key,
{
plan: cfg.plan,
status,
partnerTier: license.tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
);
return res.redirect(
`/onboarding/workspace?license_key=${encodeURIComponent(license.license_key)}`,
);
}
await subscriptionRepo.upsertByPartnerLicenseKey(db, license.license_key, {
plan: cfg.plan,
status,
partnerTier: license.tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
referenceId: workspace.publicId,
});
if (isActive) {
await workspaceRepo.update(db, workspace.publicId, { plan: cfg.plan });
}
return res.redirect(`/?partner_activated=1`);
}),
);

View File

@@ -0,0 +1,64 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
export default withRateLimit(
{ points: 20, duration: 60 },
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const { license_key } = req.query;
if (!license_key || typeof license_key !== "string") {
return res.redirect("/boards?partner_error=missing_license");
}
const { db, user } = await createNextApiContext(req);
if (!user) {
return res.redirect(
`/login?next=${encodeURIComponent(`/api/partner/link?license_key=${license_key}`)}`,
);
}
const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key);
if (!sub) {
return res.redirect("/boards?partner_error=invalid_license");
}
if (sub.status !== "active") {
return res.redirect("/boards?partner_error=license_inactive");
}
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
const workspace = memberships?.[0]?.workspace;
if (!workspace) {
return res.redirect(
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
);
}
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: sub.plan,
status: sub.status,
partnerTier: sub.partnerTier ?? 1,
seats: sub.seats ?? null,
unlimitedSeats: sub.unlimitedSeats,
referenceId: workspace.publicId,
});
await workspaceRepo.update(db, workspace.publicId, {
plan: sub.plan as "free" | "team" | "pro" | "enterprise",
});
return res.redirect("/boards?partner_activated=1");
}),
);

View File

@@ -0,0 +1,212 @@
import { createHmac, timingSafeEqual } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import type { Readable } from "node:stream";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { getActiveSubscriptions } from "@kan/shared/utils";
import { tierConfig } from "./_utils";
const log = createLogger("api");
async function buffer(readable: Readable) {
const chunks: Buffer[] = [];
for await (const chunk of readable) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}
function verifySignature(
rawBody: string,
signature: string,
timestamp: string,
): boolean {
const secret = process.env.PARTNER_API_KEY;
if (!secret) return false;
const ts = Number(timestamp);
if (isNaN(ts) || Date.now() - ts > 300_000) return false;
const payload = `${timestamp}${rawBody}`;
const expected = createHmac("sha256", secret).update(payload).digest("hex");
try {
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
} catch {
return false;
}
}
interface WebhookPayload {
event:
| "purchase"
| "activate"
| "deactivate"
| "upgrade"
| "downgrade"
| "migrate";
license_key: string;
license_status: string;
tier: number;
prev_license_key?: string;
}
export default withApiLogging(
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const signatureHeader = process.env.PARTNER_SIGNATURE_HEADER;
const timestampHeader = process.env.PARTNER_TIMESTAMP_HEADER;
if (!signatureHeader || !timestampHeader) {
log.error("Webhook signature headers not configured");
return res.status(500).json({ message: "Server misconfiguration" });
}
const signature = req.headers[signatureHeader] as string | undefined;
const timestamp = req.headers[timestampHeader] as string | undefined;
if (!signature || !timestamp) {
return res.status(400).json({ message: "Missing signature headers" });
}
const buf = await buffer(req as unknown as Readable);
const rawBody = buf.toString("utf8");
if (!verifySignature(rawBody, signature, timestamp)) {
return res.status(401).json({ message: "Invalid signature" });
}
const payload = JSON.parse(rawBody) as WebhookPayload;
const { event, license_key, license_status, tier, prev_license_key } =
payload;
log.info({ headers: req.headers, payload }, "partner webhook received");
const { db } = await createNextApiContext(req);
switch (event) {
case "purchase": {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: license_status,
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
break;
}
case "activate": {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
break;
}
case "deactivate": {
const sub = await subscriptionRepo.getByPartnerLicenseKey(
db,
license_key,
);
if (sub) {
const [, allSubs] = await Promise.all([
subscriptionRepo.updateById(db, sub.id, {
plan: "free",
status: "inactive",
}),
sub.referenceId
? subscriptionRepo.getByReferenceId(db, sub.referenceId)
: Promise.resolve([]),
]);
if (sub.referenceId) {
const hasActiveSub = getActiveSubscriptions(allSubs).some(
(s) => s.id !== sub.id,
);
if (!hasActiveSub) {
await workspaceRepo.update(db, sub.referenceId, { plan: "free" });
}
}
}
break;
}
case "upgrade":
case "downgrade": {
const lookupKey = prev_license_key ?? license_key;
const sub = await subscriptionRepo.getByPartnerLicenseKey(
db,
lookupKey,
);
if (sub) {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
referenceId: sub.referenceId ?? undefined,
});
if (prev_license_key) {
await subscriptionRepo.updateById(db, sub.id, {
status: "inactive",
});
}
if (sub.referenceId) {
await workspaceRepo.update(db, sub.referenceId, { plan: cfg.plan });
}
}
break;
}
case "migrate": {
if (prev_license_key) {
const sub = await subscriptionRepo.getByPartnerLicenseKey(
db,
prev_license_key,
);
if (sub) {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
referenceId: sub.referenceId ?? undefined,
});
await subscriptionRepo.updateById(db, sub.id, {
status: "inactive",
});
}
}
break;
}
default:
log.warn({ event }, "Unhandled partner webhook event");
}
return res.status(200).json({ success: true, event });
},
);
export const config = {
api: {
bodyParser: false,
},
};

View File

@@ -2,34 +2,34 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { env } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
import { createStripeClient } from "@kan/stripe";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createStripeClient } from "@kan/stripe";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user } = await createNextApiContext(req);
if (!user?.stripeCustomerId) {
return res.status(404).json({ error: "No billing account found" });
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
});
try {
const { user } = await createNextApiContext(req);
return res.status(200).json({ url: session.url });
} catch (error) {
console.error("Error:", error);
return res.status(500).json({ error: "Error creating portal session" });
}
},
if (!user?.stripeCustomerId) {
return res.status(404).json({ error: "No billing account found" });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
});
return res.status(200).json({ url: session.url });
} catch (error) {
return res.status(500).json({ error: "Error creating portal session" });
}
}),
);

View File

@@ -3,10 +3,13 @@ import { env } from "next-runtime-env";
import { z } from "zod";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { assertPermission } from "@kan/api/utils/permissions";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateUID } from "@kan/shared/utils";
import { createStripeClient } from "@kan/stripe";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const workspaceSlugSchema = z
.string()
@@ -17,21 +20,24 @@ const workspaceSlugSchema = z
interface CheckoutSessionRequest {
successUrl: string;
cancelUrl: string;
slug: string;
workspacePublicId: string;
stripeCustomerId: string;
plan: "team" | "pro";
billing?: string;
workspacePublicId?: string;
slug?: string;
workspaceName?: string;
workspaceDescription?: string;
workspaceSlug?: string;
}
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user, db } = await createNextApiContext(req);
if (!user) {
@@ -39,81 +45,107 @@ export default withRateLimit(
}
const body = req.body as CheckoutSessionRequest;
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
const {
successUrl,
cancelUrl,
plan,
billing,
workspacePublicId,
slug,
workspaceName,
workspaceDescription,
workspaceSlug,
} = body;
if (!successUrl || !cancelUrl || !workspacePublicId) {
if (!successUrl || !cancelUrl || !plan) {
return res.status(400).json({ error: "Missing required fields" });
}
if (slug) {
const slugResult = workspaceSlugSchema.safeParse(slug);
if (!workspacePublicId && !workspaceName) {
return res
.status(400)
.json({ error: "Must provide workspacePublicId or workspaceName" });
}
const resolvedSlug = slug ?? workspaceSlug;
if (resolvedSlug) {
const slugResult = workspaceSlugSchema.safeParse(resolvedSlug);
if (!slugResult.success) {
return new Response(
JSON.stringify({ error: "Invalid workspace slug" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
return res.status(400).json({ error: "Invalid workspace slug" });
}
}
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
let resolvedWorkspacePublicId = workspacePublicId;
let subscriptionId: number | undefined;
const isMemberOfWorkspace = workspace.some(
({ workspace }) => workspace.publicId === body.workspacePublicId,
);
if (workspacePublicId) {
// Existing workspace upgrade
const workspace = await workspaceRepo.getByPublicId(
db,
workspacePublicId,
);
if (!isMemberOfWorkspace) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 403,
headers: { "Content-Type": "application/json" },
if (!workspace) {
return res.status(404).json({ error: "Workspace not found" });
}
try {
await assertPermission(db, user.id, workspace.id, "workspace:manage");
} catch {
return res.status(403).json({ error: "Unauthorized" });
}
const subscription = await subscriptionRepo.create(db, {
plan: plan,
referenceId: workspacePublicId,
userId: user.id,
stripeCustomerId: user.stripeCustomerId ?? "",
status: "incomplete",
});
subscriptionId = subscription?.id;
if (!subscriptionId) {
return res.status(500).json({ error: "Error creating subscription" });
}
} else {
resolvedWorkspacePublicId = generateUID();
}
const subscription = await subscriptionRepo.create(db, {
plan: "pro",
referenceId: workspacePublicId,
userId: user.id,
stripeCustomerId: user.stripeCustomerId ?? "",
status: "incomplete",
});
const subscriptionId = subscription?.id;
if (!subscriptionId) {
return res.status(500).json({ error: "Error creating subscription" });
}
const isTeam = body.plan === "team";
const annualPriceId = isTeam
? (process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID ??
process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID)
: (process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID ??
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID);
const monthlyPriceId = isTeam
? process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID;
const priceId = billing === "annual" ? annualPriceId : monthlyPriceId;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_collection: "always",
line_items: [
{
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
quantity: 1,
},
],
subscription_data: {
trial_period_days: 14,
},
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: { trial_period_days: 14 },
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}?workspacePublicId=${resolvedWorkspacePublicId}`,
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
client_reference_id: workspacePublicId,
client_reference_id: resolvedWorkspacePublicId,
customer: user.stripeCustomerId ?? undefined,
metadata: {
...(slug && { workspaceSlug: slug }),
workspacePublicId,
workspacePublicId: resolvedWorkspacePublicId!,
userId: user.id,
subscriptionId,
userEmail: user.email ?? "",
...(resolvedSlug && { workspaceSlug: resolvedSlug }),
...(workspaceName && { workspaceName }),
...(workspaceDescription && { workspaceDescription }),
...(subscriptionId && { subscriptionId: String(subscriptionId) }),
plan: plan,
isNewWorkspace: workspaceName ? "true" : "false",
},
});
return res.status(200).json({ url: session.url });
} catch (error) {
console.error("Error:", error);
return res.status(500).json({ error: "Error creating checkout session" });
}
},
}),
);

View File

@@ -1,10 +1,15 @@
import { randomUUID } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import type { Readable } from "node:stream";
import { createNextApiContext } from "@kan/api/trpc";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { createStripeClient } from "@kan/stripe";
const log = createLogger("api");
async function buffer(readable: Readable) {
const chunks = [];
for await (const chunk of readable) {
@@ -18,6 +23,9 @@ export default async function handler(
res: NextApiResponse,
) {
const stripe = createStripeClient();
const start = Date.now();
const requestId = randomUUID();
const procedure = req.url?.split("?")[0] ?? "/api/stripe/webhook";
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
@@ -44,24 +52,85 @@ export default async function handler(
switch (event.type) {
case "checkout.session.completed": {
const checkoutSession = event.data.object;
const meta = checkoutSession.metadata;
const metaData = checkoutSession.metadata;
if (!meta?.workspacePublicId) break;
if (metaData?.workspacePublicId) {
await workspaceRepo.update(db, metaData.workspacePublicId, {
...(metaData.workspaceSlug && { slug: metaData.workspaceSlug }),
plan: "pro",
const plan = meta.plan === "team" ? "team" : ("pro" as const);
if (
meta.isNewWorkspace === "true" &&
meta.workspaceName &&
meta.userId &&
meta.userEmail
) {
const existing = await workspaceRepo.getByPublicId(
db,
meta.workspacePublicId,
);
if (!existing) {
const slug = meta.workspaceSlug ?? meta.workspacePublicId;
await workspaceRepo.create(db, {
publicId: meta.workspacePublicId,
name: meta.workspaceName,
slug,
plan,
createdBy: meta.userId,
createdByEmail: meta.userEmail,
...(meta.workspaceDescription && {
description: meta.workspaceDescription,
}),
});
await subscriptionRepo.create(db, {
plan,
referenceId: meta.workspacePublicId,
userId: meta.userId,
stripeCustomerId: checkoutSession.customer as string,
status: "active",
});
}
} else {
await workspaceRepo.update(db, meta.workspacePublicId, {
plan,
...(plan === "pro" &&
meta.workspaceSlug && { slug: meta.workspaceSlug }),
});
}
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
log.warn({ eventType: event.type }, "Unhandled Stripe event type");
}
log.info(
{
requestId,
procedure,
transport: "rest",
duration: Date.now() - start,
status: 200,
input: { eventType: event.type, eventId: event.id },
},
"API OK",
);
return res.status(200).json({ received: true });
} catch (err) {
console.error("Webhook error:", err);
log.error(
{
requestId,
procedure,
transport: "rest",
duration: Date.now() - start,
status: 400,
err,
},
"API error",
);
return res.status(400).json({ message: "Webhook handler failed" });
}
}

View File

@@ -1,52 +1,58 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { addYears } from "date-fns";
import { createNextApiContext } from "@kan/api/trpc";
import { integrations } from "@kan/db/schema";
import { addYears } from "date-fns";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { integrations } from "@kan/db/schema";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const { user } = await createNextApiContext(req);
const { user } = await createNextApiContext(req);
if (!user)
return res.status(401).json({ message: "User not authenticated" });
if (!user)
return res.status(401).json({ message: "User not authenticated" });
const apiKey = process.env.TRELLO_APP_API_KEY;
const apiKey = process.env.TRELLO_APP_API_KEY;
if (!apiKey)
return res.status(500).json({ message: "Trello API key not set in Environment Variables" });
if (!apiKey)
return res
.status(500)
.json({ message: "Trello API key not set in Environment Variables" });
const token = req.body.token;
const token = req.body.token;
if (!token)
return res.status(400).json({ message: "No token found" });
if (!token) return res.status(400).json({ message: "No token found" });
try {
const { db } = await createNextApiContext(req);
try {
const { db } = await createNextApiContext(req);
await db.insert(integrations).values({
provider: "trello",
userId: user.id,
accessToken: token,
expiresAt: addYears(new Date(), 1),
}).onConflictDoUpdate({
set: {
accessToken: token,
expiresAt: addYears(new Date(), 1),
},
target: [integrations.userId, integrations.provider],
});
await db
.insert(integrations)
.values({
provider: "trello",
userId: user.id,
accessToken: token,
expiresAt: addYears(new Date(), 1),
})
.onConflictDoUpdate({
set: {
accessToken: token,
expiresAt: addYears(new Date(), 1),
},
target: [integrations.userId, integrations.provider],
});
return res.status(200).json({ message: "Trello authentication successful" });
} catch (err) {
console.error("Trello authentication error:", err);
return res.status(400).json({ message: "Trello authentication failed" });
}
},
);
return res
.status(200)
.json({ message: "Trello authentication successful" });
} catch (err) {
return res.status(400).json({ message: "Trello authentication failed" });
}
}),
);

View File

@@ -3,9 +3,11 @@ import { Novu } from "@novu/api";
import { jwtVerify } from "jose";
import { z } from "zod";
import { env } from "~/env";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { env } from "~/env";
const requestSchema = z.object({
token: z.string().min(1),
});
@@ -22,84 +24,85 @@ const textEncoder = new TextEncoder();
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
return res.status(404).json({
success: false,
error: "Unsubscribe endpoint is not available.",
code: "UNAVAILABLE",
});
}
withApiLogging(
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
return res.status(404).json({
success: false,
error: "Unsubscribe endpoint is not available.",
code: "UNAVAILABLE",
});
}
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).json({
success: false,
error: "Method not allowed.",
code: "METHOD_NOT_ALLOWED",
});
}
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).json({
success: false,
error: "Method not allowed.",
code: "METHOD_NOT_ALLOWED",
});
}
const parsedBody = requestSchema.safeParse(req.body);
const parsedBody = requestSchema.safeParse(req.body);
if (!parsedBody.success) {
return res.status(400).json({
success: false,
error: "Invalid request payload.",
code: "BAD_REQUEST",
});
}
if (!parsedBody.success) {
return res.status(400).json({
success: false,
error: "Invalid request payload.",
code: "BAD_REQUEST",
});
}
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
return res.status(500).json({
success: false,
error: "Unsubscribe service is not configured.",
code: "NOT_CONFIGURED",
});
}
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
return res.status(500).json({
success: false,
error: "Unsubscribe service is not configured.",
code: "NOT_CONFIGURED",
});
}
let payload: z.infer<typeof tokenPayloadSchema>;
let payload: z.infer<typeof tokenPayloadSchema>;
try {
const verified = await jwtVerify(
parsedBody.data.token,
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
{
// We intentionally do not use exp/iat claims
// tokens are long-lived and validated only by signature + payload.
clockTolerance: "0s",
},
);
payload = tokenPayloadSchema.parse(verified.payload);
} catch {
return res.status(401).json({
success: false,
error: "Your unsubscribe link is invalid or has expired.",
code: "INVALID_TOKEN",
});
}
try {
const verified = await jwtVerify(
parsedBody.data.token,
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
{
// We intentionally do not use exp/iat claims
// tokens are long-lived and validated only by signature + payload.
clockTolerance: "0s",
},
);
payload = tokenPayloadSchema.parse(verified.payload);
} catch {
return res.status(401).json({
success: false,
error: "Your unsubscribe link is invalid or has expired.",
code: "INVALID_TOKEN",
});
}
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
try {
await novu.subscribers.preferences.update(
{
channels: {
email: false,
},
},
payload.subscriberId,
);
} catch (error) {
console.error("Failed to update Novu preferences", error);
return res.status(502).json({
success: false,
error:
"We could not update your email preferences right now. Please try again later.",
code: "NOVU_ERROR",
});
}
try {
await novu.subscribers.preferences.update(
{
channels: {
email: false,
},
},
payload.subscriberId,
);
} catch (error) {
return res.status(502).json({
success: false,
error:
"We could not update your email preferences right now. Please try again later.",
code: "NOVU_ERROR",
});
}
return res.status(200).json({ success: true });
},
return res.status(200).json({ success: true });
},
),
);

View File

@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { Upload } from "@aws-sdk/lib-storage";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { assertPermission } from "@kan/api/utils/permissions";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as cardRepo from "@kan/db/repository/card.repo";
@@ -22,7 +23,7 @@ export const config = {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
@@ -36,7 +37,9 @@ export default withRateLimit(
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
if (!bucket) {
return res.status(500).json({ error: "Attachments bucket not configured" });
return res
.status(500)
.json({ error: "Attachments bucket not configured" });
}
const cardPublicId = req.query.cardPublicId;
@@ -55,7 +58,9 @@ export default withRateLimit(
}
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" });
return res
.status(400)
.json({ error: "Missing or invalid content length" });
}
if (contentLength > MAX_SIZE_BYTES) {
@@ -129,8 +134,7 @@ export default withRateLimit(
return res.status(200).json({ attachment });
} catch (error) {
console.error("Attachment upload failed", error);
return res.status(500).json({ error: "Internal server error" });
}
},
}),
);

View File

@@ -2,13 +2,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { createNextApiContext } from "@kan/api/trpc";
import * as userRepo from "@kan/db/repository/user.repo";
import { env } from "~/env";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as userRepo from "@kan/db/repository/user.repo";
import { createS3Client } from "@kan/shared/utils";
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
import { env } from "~/env";
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 = {
@@ -19,7 +23,7 @@ export const config = {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
@@ -51,7 +55,9 @@ export default withRateLimit(
}
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" });
return res
.status(400)
.json({ error: "Missing or invalid content length" });
}
if (contentLength > MAX_SIZE_BYTES) {
@@ -93,9 +99,7 @@ export default withRateLimit(
user: updatedUser,
});
} catch (error) {
console.error("Avatar upload failed", error);
return res.status(500).json({ error: "Internal server error" });
}
},
}),
);

View File

@@ -0,0 +1,31 @@
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { env } from "next-runtime-env";
import { authClient } from "@kan/auth/client";
import { PageHead } from "~/components/PageHead";
import SelectPlanView from "~/views/onboarding/select-plan";
export default function SelectPlanPage() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
useEffect(() => {
if (!isPending && !session?.user) {
router.push("/login");
}
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
router.push("/boards");
}
}, [session, isPending, router]);
if (isPending || !session?.user) return null;
return (
<>
<PageHead title="Select plan | kan.bn" />
<SelectPlanView />
</>
);
}

View File

@@ -0,0 +1,28 @@
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { authClient } from "@kan/auth/client";
import { PageHead } from "~/components/PageHead";
import WorkspaceDetailsView from "~/views/onboarding/workspace-details";
export default function WorkspaceDetailsPage() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
useEffect(() => {
if (!isPending && !session?.user) router.push("/login");
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud")
router.push("/boards");
}, [session, isPending, router]);
if (isPending || !session?.user) return null;
return (
<>
<PageHead title="Create workspace | kan.bn" />
<WorkspaceDetailsView />
</>
);
}

View File

@@ -0,0 +1,117 @@
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { authClient } from "@kan/auth/client";
import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
export default function PartnerActivatePage() {
const router = useRouter();
const searchParams = useSearchParams();
const licenseKey = searchParams.get("license_key");
const error = searchParams.get("error");
const partnerName = env("NEXT_PUBLIC_PARTNER_NAME");
const { data: session, isPending } = authClient.useSession();
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
useEffect(() => {
if (licenseKey) {
localStorage.setItem("partnerLicenseKey", licenseKey);
}
}, [licenseKey]);
useEffect(() => {
if (!isPending && session?.user && licenseKey) {
router.push(
`/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`,
);
} else if (!isPending && session?.user && !licenseKey) {
router.push("/boards");
}
}, [session, isPending, licenseKey, router]);
if (isPending || (session?.user && licenseKey)) return null;
return (
<>
<PageHead title={t`Activate | 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-2 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
{isMagicLinkSent ? t`Check your inbox` : t`Activate your account`}
</p>
<p className="mb-10 text-sm text-light-800 dark:text-dark-800">
{isMagicLinkSent ? (
<Trans>We sent a link to {magicLinkRecipient}</Trans>
) : partnerName ? (
<Trans>
Sign in or create an account to activate your {partnerName}{" "}
license
</Trans>
) : (
t`Sign in or create an account to activate your license`
)}
</p>
{error && (
<div className="mb-4 w-full rounded-md bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400 sm:max-w-md">
{t`Something went wrong during activation. Please try again.`}
</div>
)}
{!isMagicLinkSent && (
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<Auth
setIsMagicLinkSent={(val, recipient) => {
setIsMagicLinkSent(val);
setMagicLinkRecipient(recipient);
}}
callbackURL={
licenseKey
? `/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`
: "/boards"
}
/>
</div>
</div>
)}
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
<Trans>
Already have an account?{" "}
<span className="underline">
<Link
href={
licenseKey
? `/login?next=${encodeURIComponent(`/api/partner/link?license_key=${licenseKey}`)}`
: "/login"
}
>
Sign in
</Link>
</span>
</Trans>
</p>
</div>
<PatternedBackground />
</div>
</main>
</>
);
}

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,31 @@
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { authClient } from "@kan/auth/client";
import { PageHead } from "~/components/PageHead";
import SelectPlanView from "~/views/onboarding/select-plan";
export default function UpgradeSelectPlanPage() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
useEffect(() => {
if (!isPending && !session?.user) {
router.push("/login");
}
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
router.push("/boards");
}
}, [session, isPending, router]);
if (isPending || !session?.user) return null;
return (
<>
<PageHead title="Upgrade | kan.bn" />
<SelectPlanView />
</>
);
}

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

@@ -17,8 +17,10 @@ interface Workspace {
description: string | null | undefined;
publicId: string;
slug: string | undefined;
plan: "free" | "pro" | "enterprise" | undefined;
plan: "free" | "team" | "pro" | "enterprise" | undefined;
role: "admin" | "member" | "guest";
weekStartDay: 0 | 1 | 6;
cardPrefix: string;
}
const initialWorkspace: Workspace = {
@@ -26,15 +28,17 @@ const initialWorkspace: Workspace = {
description: null,
publicId: "",
slug: "",
plan: "free",
plan: "free" as const,
role: "member",
weekStartDay: 1,
cardPrefix: "",
};
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,
@@ -48,7 +52,18 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
const workspacePublicId = useSearchParams().get("workspacePublicId");
const { data, isLoading } = api.workspace.all.useQuery();
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<string | null>(
workspacePublicId,
);
const pollAttemptsRef = React.useRef(0);
const MAX_POLL_ATTEMPTS = 5;
const { data, isLoading, isFetching } = api.workspace.all.useQuery(
undefined,
{
refetchInterval: pendingWorkspaceId ? 2000 : false,
},
);
const utils = api.useUtils();
const switchWorkspace = (_workspace: Workspace) => {
@@ -64,7 +79,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
if (!data?.length) {
if (!isLoading) setHasLoaded(true);
if (!isLoading && !isFetching) setHasLoaded(true);
return;
}
@@ -79,6 +94,8 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
slug: workspace.slug,
description: workspace.description,
plan: workspace.plan,
weekStartDay: workspace.weekStartDay,
cardPrefix: workspace.cardPrefix,
hasLoaded: true,
})) as Workspace[];
@@ -86,42 +103,72 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
}
if (storedWorkspaceId !== null) {
const newData = data;
const selectedWorkspace = newData.find(
const selectedWorkspace = data.find(
({ workspace }) => workspace.publicId === storedWorkspaceId,
);
if (!selectedWorkspace?.workspace) return;
if (!selectedWorkspace?.workspace) {
if (pendingWorkspaceId) {
pollAttemptsRef.current += 1;
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
setPendingWorkspaceId(null);
localStorage.removeItem("workspacePublicId");
} else {
return;
}
} else {
// Clear stale workspacePublicId from localStorage
localStorage.removeItem("workspacePublicId");
}
} else {
pollAttemptsRef.current = 0;
setPendingWorkspaceId(null);
setWorkspace({
publicId: selectedWorkspace.workspace.publicId,
name: selectedWorkspace.workspace.name,
slug: selectedWorkspace.workspace.slug,
plan: selectedWorkspace.workspace.plan,
description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role,
});
setWorkspace({
publicId: selectedWorkspace.workspace.publicId,
name: selectedWorkspace.workspace.name,
slug: selectedWorkspace.workspace.slug,
plan: selectedWorkspace.workspace.plan,
description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role as "admin" | "member" | "guest",
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
cardPrefix: selectedWorkspace.workspace.cardPrefix,
});
if (workspacePublicId) {
router.push(`/boards`);
localStorage.setItem("workspacePublicId", workspacePublicId);
if (workspacePublicId) {
router.push(`/boards`);
localStorage.setItem("workspacePublicId", workspacePublicId);
}
setHasLoaded(true);
return;
}
} else {
const primaryWorkspace = data[0]?.workspace;
const primaryWorkspaceRole = data[0]?.role;
if (!primaryWorkspace || !primaryWorkspaceRole) return;
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
setWorkspace({
publicId: primaryWorkspace.publicId,
name: primaryWorkspace.name,
slug: primaryWorkspace.slug,
plan: primaryWorkspace.plan,
description: primaryWorkspace.description,
role: primaryWorkspaceRole,
});
}
}, [data, isLoading, workspacePublicId, router]);
const primaryWorkspace = data[0]?.workspace;
const primaryWorkspaceRole = data[0]?.role;
if (!primaryWorkspace || !primaryWorkspaceRole) return;
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
setWorkspace({
publicId: primaryWorkspace.publicId,
name: primaryWorkspace.name,
slug: primaryWorkspace.slug,
plan: primaryWorkspace.plan,
description: primaryWorkspace.description,
role: primaryWorkspaceRole as "admin" | "member" | "guest",
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
cardPrefix: primaryWorkspace.cardPrefix,
});
setHasLoaded(true);
}, [
data,
isLoading,
isFetching,
workspacePublicId,
pendingWorkspaceId,
router,
]);
return (
<WorkspaceContext.Provider

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

@@ -1,3 +1,5 @@
import { env } from "next-runtime-env";
export const formatToArray = (
value: string | string[] | undefined,
): string[] => {
@@ -50,5 +52,21 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
return imageOrKey;
}
// Construct URL from S3 key
const useVirtualHosted = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS") === "true";
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
if (useVirtualHosted && storageDomain && bucket) {
// Virtual-hosted style: https://{bucket}.{domain}/{key}
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
}
if (storageUrl && bucket) {
// Path-style: {storageUrl}/{bucket}/{key}
return `${storageUrl}/${bucket}/${imageOrKey}`;
}
return "";
};

View File

@@ -22,8 +22,8 @@ const loadMessages = async (locale: Locale) => {
return (await import("~/locales/ru/messages")).messages;
case "pl":
return (await import("~/locales/pl/messages")).messages;
case "pt-BR":
return (await import("~/locales/pt-BR/messages")).messages;
case "ptbr":
return (await import("~/locales/ptbr/messages")).messages;
default:
return enMessages;
}

View File

@@ -1,3 +1,4 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
@@ -29,6 +30,7 @@ export default function BoardDropdown({
isFavorite?: boolean;
boardName?: string;
}) {
const router = useRouter();
const { openModal } = useModal();
const { showPopup } = usePopup();
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
@@ -47,6 +49,7 @@ export default function BoardDropdown({
: t`The board has been unarchived.`,
icon: "success",
});
void router.push(`/boards`);
} else if (variables.favorite !== undefined) {
showPopup({
header: variables.favorite

View File

@@ -16,6 +16,7 @@ import { getAvatarUrl } from "~/utils/helpers";
const Card = ({
title,
ticketNumber,
labels,
members,
checklists,
@@ -25,6 +26,7 @@ const Card = ({
dueDate,
}: {
title: string;
ticketNumber?: string | null;
labels: { name: string; colourCode: string | null }[];
members: {
publicId: string;
@@ -67,6 +69,11 @@ const Card = ({
return (
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
{ticketNumber && (
<span className="mb-1 text-xs text-light-700 dark:text-dark-800">
{ticketNumber}
</span>
)}
<span className="break-words">{title}</span>
{labels.length ||
members.length ||

View File

@@ -0,0 +1,41 @@
import { t } from "@lingui/core/macro";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
import { DueDateSelector } from "~/views/card/components/DueDateSelector";
export function CardContextDueDateModal() {
const { entityId: cardPublicId, closeModal } = useModal();
const { data: card, isLoading } = api.card.byId.useQuery(
{ cardPublicId: cardPublicId ?? "" },
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
);
if (!cardPublicId) return null;
return (
<div className="p-4">
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
{t`Set due date`}
</h2>
{isLoading ? (
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
) : (
<DueDateSelector
cardPublicId={cardPublicId}
dueDate={card?.dueDate ?? null}
/>
)}
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={closeModal}
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
>
{t`Done`}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,284 @@
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
Transition,
} from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { useState } from "react";
import { HiChevronDown } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
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";
interface CardContextDuplicateModalProps {
boardPublicId?: string;
isTemplate?: boolean;
}
export function CardContextDuplicateModal({
boardPublicId: boardPublicIdProp,
isTemplate: isTemplateProp,
}: CardContextDuplicateModalProps = {}) {
const { entityId: cardPublicId, closeModal, getModalState } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const modalState = getModalState("CARD_CONTEXT_DUPLICATE") as
| { boardPublicId: string; isTemplate?: boolean }
| undefined;
const boardPublicId =
boardPublicIdProp ?? modalState?.boardPublicId ?? "";
const isTemplate = isTemplateProp ?? modalState?.isTemplate ?? false;
const [listPublicId, setListPublicId] = useState("");
const [copyLabels, setCopyLabels] = useState(true);
const [copyMembers, setCopyMembers] = useState(true);
const [copyChecklists, setCopyChecklists] = useState(true);
const [position, setPosition] = useState<string>("");
const [title, setTitle] = useState("");
const { data: card, isLoading: isCardLoading } = api.card.byId.useQuery(
{ cardPublicId: cardPublicId ?? "" },
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
);
const boardType = isTemplate ? "template" : "regular";
const { data: board } = api.board.byId.useQuery(
{ boardPublicId, type: boardType },
{ enabled: !!boardPublicId },
);
const lists = board?.lists ?? [];
const listOptions = lists.map((l) => ({ publicId: l.publicId, name: l.name }));
const currentListPublicId = card?.list?.publicId;
const hasLabels = (card?.labels?.length ?? 0) > 0;
const hasMembers = (card?.members?.length ?? 0) > 0;
const hasChecklists = (card?.checklists?.length ?? 0) > 0;
const hasAnyCopyOption = hasLabels || hasMembers || hasChecklists;
const duplicateCard = api.card.duplicate.useMutation({
onSuccess: () => {
showPopup({
header: t`Card duplicated`,
icon: "success",
message: t`The card has been duplicated.`,
});
closeModal();
},
onError: () => {
showPopup({
header: t`Unable to duplicate card`,
message: t`Please try again.`,
icon: "error",
});
},
onSettled: async () => {
await utils.board.byId.invalidate();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!cardPublicId || !listPublicId) return;
const indexNum =
position === "" ? undefined : parseInt(position, 10);
if (
position !== "" &&
(indexNum === undefined || isNaN(indexNum) || indexNum < 0)
)
return;
duplicateCard.mutate({
cardPublicId,
listPublicId,
copyLabels,
copyMembers,
copyChecklists,
...(typeof indexNum === "number" && { index: indexNum }),
title: title.trim() || undefined,
});
};
if (!cardPublicId) return null;
return (
<form onSubmit={handleSubmit} className="p-4">
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
{t`Duplicate card`}
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`List`}
</label>
<Listbox value={listPublicId} onChange={setListPublicId}>
<div className="relative">
<ListboxButton
className={twMerge(
"relative w-full cursor-pointer rounded-md border border-light-300 bg-white py-2 pl-3 pr-10 text-left text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-light-400 focus:ring-offset-0 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:focus:ring-dark-500",
!listPublicId && "text-light-600 dark:text-dark-600",
)}
>
<span className="block truncate">
{listPublicId
? listOptions.find((o) => o.publicId === listPublicId)
?.name
: t`Select a list`}
</span>
<span className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
<HiChevronDown
className="h-4 w-4 text-light-600 dark:text-dark-600"
aria-hidden
/>
</span>
</ListboxButton>
<Transition
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute z-50 mt-1 w-full overflow-hidden rounded-md border border-light-200 bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-200">
<div className="max-h-60 overflow-y-auto py-1 pr-1 scrollbar scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
{listOptions.map((option) => {
const isCurrentList =
option.publicId === currentListPublicId;
return (
<ListboxOption
key={option.publicId}
value={option.publicId}
disabled={isCurrentList}
className={({ focus }) =>
twMerge(
"relative select-none py-2 pl-3 pr-9 text-sm",
isCurrentList
? "cursor-default opacity-50"
: "cursor-pointer",
!isCurrentList && focus
? "bg-light-200 text-light-1000 dark:bg-dark-400 dark:text-dark-1000"
: "text-light-900 dark:text-dark-900",
)
}
>
{option.name}
</ListboxOption>
);
})}
</div>
</ListboxOptions>
</Transition>
</div>
</Listbox>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`Title (optional)`}
</label>
<Input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={card?.title ?? ""}
className="w-full"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`Position in list (0 = first, leave empty for end)`}
</label>
<Input
type="number"
min={0}
value={position}
onChange={(e) => setPosition(e.target.value)}
placeholder={t`End of list`}
className="w-full"
/>
</div>
{hasAnyCopyOption && (
<div className="flex flex-col gap-2">
{hasLabels && (
<div className="flex items-center gap-3">
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
<input
type="checkbox"
checked={copyLabels}
onChange={(e) => setCopyLabels(e.target.checked)}
className={twMerge(
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
"cursor-pointer",
)}
/>
</label>
<span className="text-sm text-light-900 dark:text-dark-900">
{t`Copy labels`}
</span>
</div>
)}
{hasMembers && (
<div className="flex items-center gap-3">
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
<input
type="checkbox"
checked={copyMembers}
onChange={(e) => setCopyMembers(e.target.checked)}
className={twMerge(
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
"cursor-pointer",
)}
/>
</label>
<span className="text-sm text-light-900 dark:text-dark-900">
{t`Copy members`}
</span>
</div>
)}
{hasChecklists && (
<div className="flex items-center gap-3">
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
<input
type="checkbox"
checked={copyChecklists}
onChange={(e) => setCopyChecklists(e.target.checked)}
className={twMerge(
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
"cursor-pointer",
)}
/>
</label>
<span className="text-sm text-light-900 dark:text-dark-900">
{t`Copy checklists`}
</span>
</div>
)}
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={closeModal}>
{t`Cancel`}
</Button>
<Button
type="submit"
variant="primary"
disabled={
!listPublicId ||
duplicateCard.isPending ||
isCardLoading ||
lists.length === 0
}
>
{duplicateCard.isPending ? t`Duplicating…` : t`Duplicate`}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,53 @@
import { t } from "@lingui/core/macro";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
import LabelIcon from "~/components/LabelIcon";
import LabelSelector from "~/views/card/components/LabelSelector";
export function CardContextLabelsModal() {
const { entityId: cardPublicId, closeModal } = useModal();
const { data: card, isLoading } = api.card.byId.useQuery(
{ cardPublicId: cardPublicId ?? "" },
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
);
const boardLabels = card?.list?.board?.labels ?? [];
const selectedLabels = card?.labels ?? [];
const formattedLabels = boardLabels.map((label) => ({
key: label.publicId,
value: label.name,
selected: selectedLabels.some((l) => l.publicId === label.publicId),
leftIcon: <LabelIcon colourCode={label.colourCode} />,
}));
if (!cardPublicId) return null;
return (
<div className="p-4">
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
{t`Labels`}
</h2>
{isLoading ? (
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
) : (
<LabelSelector
cardPublicId={cardPublicId}
labels={formattedLabels}
isLoading={isLoading}
/>
)}
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={closeModal}
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
>
{t`Done`}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { t } from "@lingui/core/macro";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
import Avatar from "~/components/Avatar";
import MemberSelector from "~/views/card/components/MemberSelector";
export function CardContextMembersModal() {
const { entityId: cardPublicId, closeModal } = useModal();
const { data: card, isLoading } = api.card.byId.useQuery(
{ cardPublicId: cardPublicId ?? "" },
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
);
const board = card?.list?.board;
const workspaceMembers = board?.workspace?.members ?? [];
const selectedMembers = card?.members ?? [];
const formattedMembers = workspaceMembers.map((member) => {
const isSelected = selectedMembers.some(
(m) => m.publicId === member.publicId,
);
return {
key: member.publicId,
value: formatMemberDisplayName(
member.user?.name ?? null,
member.user?.email ?? member.email,
),
imageUrl: member.user?.image ? getAvatarUrl(member.user.image) : undefined,
selected: isSelected,
leftIcon: (
<Avatar
size="xs"
name={member.user?.name ?? ""}
imageUrl={
member.user?.image ? getAvatarUrl(member.user.image) : undefined
}
email={member.user?.email ?? member.email}
/>
),
};
});
if (!cardPublicId) return null;
return (
<div className="p-4">
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
{t`Manage members`}
</h2>
{isLoading ? (
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
) : (
<MemberSelector
cardPublicId={cardPublicId}
members={formattedMembers}
isLoading={isLoading}
/>
)}
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={closeModal}
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
>
{t`Done`}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { t } from "@lingui/core/macro";
import { useEffect, useRef } from "react";
import {
HiLink,
HiOutlineCalendar,
HiOutlineDocumentDuplicate,
HiOutlineTag,
HiOutlineTrash,
HiOutlineUserGroup,
HiOutlineArrowRightCircle,
} from "react-icons/hi2";
export type CardContextMenuAction =
| "members"
| "move"
| "labels"
| "dueDate"
| "copyLink"
| "duplicate"
| "delete";
interface CardContextMenuProps {
x: number;
y: number;
onClose: () => void;
onAction: (action: CardContextMenuAction) => void;
canEdit: boolean;
}
const MENU_ITEMS: {
action: CardContextMenuAction;
label: string;
icon: React.ReactNode;
requiresEdit: boolean;
}[] = [
{
action: "members",
label: t`Manage members`,
icon: <HiOutlineUserGroup className="h-4 w-4 shrink-0" />,
requiresEdit: true,
},
{
action: "move",
label: t`Move to another list`,
icon: <HiOutlineArrowRightCircle className="h-4 w-4 shrink-0" />,
requiresEdit: true,
},
{
action: "labels",
label: t`Add / edit label`,
icon: <HiOutlineTag className="h-4 w-4 shrink-0" />,
requiresEdit: true,
},
{
action: "dueDate",
label: t`Set due date`,
icon: <HiOutlineCalendar className="h-4 w-4 shrink-0" />,
requiresEdit: true,
},
{
action: "copyLink",
label: t`Copy link to card`,
icon: <HiLink className="h-4 w-4 shrink-0" />,
requiresEdit: false,
},
{
action: "duplicate",
label: t`Duplicate card`,
icon: <HiOutlineDocumentDuplicate className="h-4 w-4 shrink-0" />,
requiresEdit: true,
},
{
action: "delete",
label: t`Delete card`,
icon: <HiOutlineTrash className="h-4 w-4 shrink-0" />,
requiresEdit: true,
},
];
export function CardContextMenu({
x,
y,
onClose,
onAction,
canEdit,
}: CardContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
const items = MENU_ITEMS.filter((item) => !item.requiresEdit || canEdit);
return (
<div
ref={menuRef}
className="fixed z-[200] min-w-[200px] rounded-md border border-light-200 bg-white py-1 shadow-lg dark:border-dark-400 dark:bg-dark-200"
style={{ left: x, top: y }}
>
{items.map(({ action, label, icon }) => (
<button
key={action}
type="button"
onClick={() => {
onAction(action);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
{icon}
{label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { t } from "@lingui/core/macro";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
export function CardContextMoveListModal() {
const { entityId: cardPublicId, closeModal } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const { data: card, isLoading } = api.card.byId.useQuery(
{ cardPublicId: cardPublicId ?? "" },
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
);
const updateCardList = api.card.update.useMutation({
onMutate: async (vars) => {
if (!cardPublicId) return undefined;
await utils.card.byId.cancel();
const previous = utils.card.byId.getData({ cardPublicId });
utils.card.byId.setData({ cardPublicId }, (old) => {
if (!old) return old;
const list = old.list.board?.lists?.find(
(l) => l.publicId === vars.listPublicId,
);
if (!list) return old;
return {
...old,
list: { ...old.list, publicId: list.publicId, name: list.name },
};
});
return { previous };
},
onError: (_err, _vars, ctx) => {
if (cardPublicId && ctx?.previous) {
utils.card.byId.setData({ cardPublicId }, ctx.previous);
}
showPopup({
header: t`Unable to move card`,
message: t`Please try again.`,
icon: "error",
});
},
onSettled: async () => {
if (cardPublicId) {
await invalidateCard(utils, cardPublicId);
await utils.board.byId.invalidate();
}
},
});
const lists = card?.list?.board?.lists ?? [];
const currentListPublicId = card?.list?.publicId;
const handleSelectList = (listPublicId: string) => {
if (listPublicId === currentListPublicId || !cardPublicId) return;
updateCardList.mutate(
{ cardPublicId, listPublicId, index: 0 },
{ onSuccess: closeModal },
);
};
if (!cardPublicId) return null;
return (
<div className="p-4">
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
{t`Move to list`}
</h2>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300"
/>
))}
</div>
) : (
<div className="max-h-[60vh] overflow-y-auto pr-1 scrollbar scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
<ul className="space-y-1">
{lists.map((list) => (
<li key={list.publicId}>
<button
type="button"
onClick={() => handleSelectList(list.publicId)}
disabled={list.publicId === currentListPublicId}
className="w-full rounded-md px-3 py-2 text-left text-sm hover:bg-light-200 disabled:opacity-50 dark:hover:bg-dark-400"
>
{list.name}
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}

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();
@@ -144,6 +146,10 @@ export function NewCardForm({
listId: 2,
description: "",
dueDate: args.dueDate ?? null,
cardNumber: null,
comments: [],
checklists: [],
attachments: [],
labels: oldBoard.labels.filter((label) =>
args.labelPublicIds.includes(label.publicId),
),
@@ -156,6 +162,9 @@ export function NewCardForm({
...member,
deletedAt: null,
})) ?? [],
comments: [],
checklists: [],
attachments: [],
_filteredLabels: labelPublicIds.map((id) => ({ publicId: id })),
_filteredMembers: memberPublicIds.map((id) => ({ publicId: id })),
index: position === "start" ? 0 : list.cards.length,
@@ -491,6 +500,7 @@ export function NewCardForm({
setValue("dueDate", date ?? null);
setIsDateSelectorOpen(false);
}}
weekStartsOn={workspace.weekStartDay}
/>
</div>
</>
@@ -512,7 +522,7 @@ export function NewCardForm({
</div>
</div>
<div className="mt-5 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-5 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Create another`}
isChecked={isCreateAnotherEnabled}

View File

@@ -136,7 +136,7 @@ export function NewListForm({
}}
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Create another`}
isChecked={isCreateAnotherEnabled}

View File

@@ -4,6 +4,7 @@ import { useParams } from "next/navigation";
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { keepPreviousData } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { DragDropContext, Draggable } from "react-beautiful-dnd";
import { useForm } from "react-hook-form";
@@ -15,6 +16,7 @@ import {
import type { UpdateBoardInput } from "@kan/api/types";
import type { CardContextMenuAction } from "./components/CardContextMenu";
import Button from "~/components/Button";
import { DeleteLabelConfirmation } from "~/components/DeleteLabelConfirmation";
import { LabelForm } from "~/components/LabelForm";
@@ -27,14 +29,22 @@ import { Tooltip } from "~/components/Tooltip";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { useDragToScroll } from "~/hooks/useDragToScroll";
import { usePermissions } from "~/hooks/usePermissions";
import { useScrollRestore } from "~/hooks/useScrollRestore";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { formatToArray } from "~/utils/helpers";
import { DeleteCardConfirmation } from "~/views/card/components/DeleteCardConfirmation";
import BoardDropdown from "./components/BoardDropdown";
import Card from "./components/Card";
import { CardContextDueDateModal } from "./components/CardContextDueDateModal";
import { CardContextDuplicateModal } from "./components/CardContextDuplicateModal";
import { CardContextLabelsModal } from "./components/CardContextLabelsModal";
import { CardContextMembersModal } from "./components/CardContextMembersModal";
import { CardContextMenu } from "./components/CardContextMenu";
import { CardContextMoveListModal } from "./components/CardContextMoveListModal";
import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
import Filters from "./components/Filters";
@@ -54,11 +64,18 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const utils = api.useUtils();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const { openModal, modalContentType, entityId, isOpen } = useModal();
const { openModal, modalContentType, entityId, isOpen, setModalState } =
useModal();
const [selectedPublicListId, setSelectedPublicListId] =
useState<PublicListId>("");
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
cardPublicId: string;
} | null>(null);
const { ref: scrollRef, onMouseDown } = useDragToScroll({
enabled: true,
direction: "horizontal",
@@ -124,11 +141,24 @@ 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 +171,13 @@ 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();
@@ -254,6 +291,56 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
setSelectedPublicListId(publicBoardId);
};
const handleCardContextMenuAction = (action: CardContextMenuAction) => {
const cardPublicId = contextMenu?.cardPublicId;
if (!cardPublicId) return;
setContextMenu(null);
if (action === "copyLink") {
const path = isTemplate
? `/templates/${boardId}/cards/${cardPublicId}`
: `/cards/${cardPublicId}`;
const url = `${typeof window !== "undefined" ? window.location.origin : ""}${path}`;
void navigator.clipboard.writeText(url).then(
() => {
showPopup({
header: t`Link copied`,
icon: "success",
message: t`Card URL copied to clipboard`,
});
},
() => {
showPopup({
header: t`Unable to copy link`,
icon: "error",
message: t`Please try again.`,
});
},
);
return;
}
if (action === "duplicate") {
setModalState("CARD_CONTEXT_DUPLICATE", {
boardPublicId: boardId ?? "",
isTemplate: !!isTemplate,
});
openModal("CARD_CONTEXT_DUPLICATE", cardPublicId);
return;
}
if (action === "delete") {
openModal("DELETE_CARD", cardPublicId);
return;
}
const modalType =
action === "members"
? "CARD_CONTEXT_MEMBERS"
: action === "move"
? "CARD_CONTEXT_MOVE_LIST"
: action === "labels"
? "CARD_CONTEXT_LABELS"
: "CARD_CONTEXT_DUE_DATE";
openModal(modalType, cardPublicId);
};
const onDragEnd = ({
source: _source,
destination,
@@ -390,6 +477,49 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
>
<EditYouTubeModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CARD_CONTEXT_MEMBERS"}
>
<CardContextMembersModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CARD_CONTEXT_MOVE_LIST"}
>
<CardContextMoveListModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CARD_CONTEXT_LABELS"}
>
<CardContextLabelsModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CARD_CONTEXT_DUE_DATE"}
>
<CardContextDueDateModal />
</Modal>
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "CARD_CONTEXT_DUPLICATE"}
>
<CardContextDuplicateModal
boardPublicId={boardId ?? ""}
isTemplate={!!isTemplate}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_CARD"}
>
<DeleteCardConfirmation
cardPublicId={entityId}
boardPublicId={boardId ?? ""}
/>
</Modal>
</>
);
};
@@ -592,24 +722,46 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
)
e.preventDefault();
}}
onContextMenu={(e) => {
if (
card.publicId.startsWith(
"PLACEHOLDER",
) ||
env("NEXT_PUBLIC_KAN_ENV") ===
"cloud"
)
return;
e.preventDefault();
setContextMenu({
x: e.clientX,
y: e.clientY,
cardPublicId: card.publicId,
});
}}
key={card.publicId}
href={
isTemplate
? `/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}
>
<Card
title={card.title}
ticketNumber={
card.cardNumber != null
? `${boardData.workspace.cardPrefix}-${card.cardNumber}`
: null
}
labels={card.labels}
members={card.members}
checklists={card.checklists ?? []}
@@ -640,6 +792,15 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
</>
) : null}
</div>
{contextMenu && (
<CardContextMenu
x={contextMenu.x}
y={contextMenu.y}
onClose={() => setContextMenu(null)}
onAction={handleCardContextMenuAction}
canEdit={!!canEditCard}
/>
)}
{renderModalContent()}
</div>
</>

View File

@@ -184,7 +184,7 @@ const SelectSource = ({
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button
type="submit"
@@ -234,7 +234,7 @@ const ImportGithub: React.FC = () => {
await refetchBoards();
closeModal();
} catch (e) {
console.log(e);
console.error(e);
}
},
onError: () => {
@@ -310,7 +310,7 @@ const ImportGithub: React.FC = () => {
<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">
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Select all`}
isChecked={!!isSelectAllEnabled}
@@ -386,7 +386,7 @@ const ImportTrello: React.FC = () => {
await refetchBoards();
closeModal();
} catch (e) {
console.log(e);
console.error(e);
}
},
onError: () => {
@@ -459,7 +459,7 @@ const ImportTrello: React.FC = () => {
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Select all`}
isChecked={!!isSelectAllEnabled}

View File

@@ -148,7 +148,7 @@ export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
showTemplates={showTemplates}
customTemplates={formattedTemplates ?? []}
/>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{!isTemplate && (
<Toggle
label={t`Use template`}

View File

@@ -525,7 +525,6 @@ const ActivityList = ({
comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt}
isAuthor={activity.comment?.createdBy === sessionData?.user.id}
isAdmin={isAdmin ?? false}
isViewOnly={!!isViewOnly}
/>
);

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

@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
HiHashtag,
HiLink,
HiOutlineCheckCircle,
HiOutlineTrash,
@@ -18,11 +19,13 @@ export default function CardDropdown({
isTemplate,
boardPublicId,
cardCreatedBy,
ticketNumber,
}: {
cardPublicId: string;
isTemplate?: boolean;
boardPublicId?: string;
cardCreatedBy?: string | null;
ticketNumber?: string | null;
}) {
const { openModal } = useModal();
const { showPopup } = usePopup();
@@ -53,12 +56,40 @@ export default function CardDropdown({
}
};
const handleCopyTicketId = async () => {
if (!ticketNumber) return;
try {
await navigator.clipboard.writeText(ticketNumber);
showPopup({
header: t`ID copied`,
icon: "success",
message: t`Ticket ID copied to clipboard`,
});
} catch (error) {
console.error(error);
showPopup({
header: t`Unable to copy ID`,
icon: "error",
message: t`Please try again.`,
});
}
};
const items = [
{
label: t`Copy card link`,
action: handleCopyCardLink,
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
...(ticketNumber
? [
{
label: t`Copy ticket ID`,
action: handleCopyTicketId,
icon: <HiHashtag className="h-[16px] w-[16px] text-dark-900" />,
},
]
: []),
...(canEditCard
? [
{

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

@@ -78,6 +78,7 @@ export default function LabelSelector({
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.board.byId.invalidate();
},
});

View File

@@ -58,6 +58,7 @@ export default function ListSelector({
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.board.byId.invalidate();
},
});

View File

@@ -85,6 +85,7 @@ export default function MemberSelector({
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.board.byId.invalidate();
},
});

View File

@@ -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;
@@ -331,6 +340,14 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
>
{board?.name}
</Link>
{card.cardNumber != null && card.list.board.workspace.cardPrefix && (
<>
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<Dropdown
@@ -338,6 +355,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
isTemplate={isTemplate}
boardPublicId={boardId}
cardCreatedBy={card?.createdBy}
ticketNumber={
card.cardNumber != null && card.list.board.workspace.cardPrefix
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
: null
}
/>
<Link
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}

View File

@@ -13,7 +13,6 @@ import { z } from "zod";
import type { InviteMemberInput } from "@kan/api/types";
import type { Subscription } from "@kan/shared/utils";
import { authClient } from "@kan/auth/client";
import { getSubscriptionByPlan } from "@kan/shared/utils";
import Button from "~/components/Button";
@@ -25,15 +24,11 @@ import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function InviteMemberForm({
numberOfMembers,
subscriptions,
unlimitedSeats,
userId,
}: {
numberOfMembers: number;
subscriptions: Subscription[] | undefined;
unlimitedSeats: boolean;
userId: string | undefined;
}) {
const utils = api.useUtils();
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
@@ -153,6 +148,9 @@ export function InviteMemberForm({
const hasTeamSubscription = !!teamSubscription;
const hasProSubscription = !!proSubscription;
const isPartnerTier = !!(
teamSubscription?.partnerTier ?? proSubscription?.partnerTier
);
let isYearly = false;
let price = t`$10/month`;
@@ -175,13 +173,13 @@ export function InviteMemberForm({
inviteMember.mutate(member);
};
const isFreePlan =
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription;
const handleInviteLinkToggle = async () => {
if (
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
)
return handleUpgrade();
if (isFreePlan) return;
setIsLoadingInviteLink(true);
@@ -219,31 +217,6 @@ export function InviteMemberForm({
}
};
const handleUpgrade = async () => {
const { data, error } = await authClient.subscription.upgrade({
plan: "team",
referenceId: workspace.publicId,
metadata: { userId },
seats: numberOfMembers,
successUrl: "/members",
cancelUrl: "/members",
returnUrl: "/members",
disableRedirect: true,
});
if (data?.url) {
window.location.href = data.url;
}
if (error) {
showPopup({
header: t`Error upgrading subscription`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
}
};
useEffect(() => {
const emailElement: HTMLElement | null =
document.querySelector<HTMLElement>("#email");
@@ -270,11 +243,7 @@ export function InviteMemberForm({
<Input
id="email"
placeholder={t`Email`}
disabled={
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
}
disabled={isFreePlan}
{...register("email", { required: true })}
onKeyDown={async (e) => {
if (e.key === "Enter") {
@@ -285,78 +254,86 @@ export function InviteMemberForm({
errorMessage={errors.email?.message}
/>
)}
{(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
<div className="my-4">
<div className="relative">
<Input
value={inviteLink}
className="pr-10 text-sm text-light-900 dark:text-dark-900"
readOnly
/>
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
onClick={copyToClipboard}
>
{copied ? (
<HiMiniCheck className="h-5 w-5 text-green-600" />
) : (
<HiOutlineDocumentDuplicate className="h-5 w-5" />
)}
</button>
{!isFreePlan &&
(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
<div className="my-4">
<div className="relative">
<Input
value={inviteLink}
className="pr-10 text-sm text-light-900 dark:text-dark-900"
readOnly
/>
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
onClick={copyToClipboard}
>
{copied ? (
<HiMiniCheck className="h-5 w-5 text-green-600" />
) : (
<HiOutlineDocumentDuplicate className="h-5 w-5" />
)}
</button>
</div>
<div className="mt-2 flex items-start gap-1">
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
<p className="text-xs text-gray-500 dark:text-dark-900">
{t`Anyone with this link can join your workspace`}
</p>
</div>
</div>
<div className="mt-2 flex items-start gap-1">
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
<p className="text-xs text-gray-500 dark:text-dark-900">
{t`Anyone with this link can join your workspace`}
</p>
</div>
</div>
)}
)}
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
{hasTeamSubscription || hasProSubscription ? (
<div>
<span className="font-medium text-emerald-500 dark:text-emerald-400">
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
</span>
<p className="mt-1">
{unlimitedSeats
? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
</p>
</div>
) : (
<div>
<span className="font-medium text-light-950 dark:text-dark-950">
{t`Free Plan`}
</span>
<p className="mt-1">
{t`Inviting members requires a Team Plan. You'll be redirected to upgrade your workspace.`}
</p>
</div>
)}
</div>
)}
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!isPartnerTier &&
!unlimitedSeats && (
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
{hasTeamSubscription || hasProSubscription ? (
<div>
<span className="font-medium text-emerald-500 dark:text-emerald-400">
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
</span>
{!isPartnerTier && (
<p className="mt-1">
{unlimitedSeats
? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
</p>
)}
</div>
) : (
<div>
<span className="font-medium text-light-950 dark:text-dark-950">
{t`Free Plan`}
</span>
<p className="mt-1">
{t`Inviting members requires a Team or Pro plan. You'll be redirected to upgrade your workspace.`}
</p>
</div>
)}
</div>
)}
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={
isShareInviteLinkEnabled
? t`Deactivate invite link`
: t`Create invite link`
}
isChecked={isShareInviteLinkEnabled}
onChange={handleInviteLinkToggle}
/>
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{!isFreePlan && (
<Toggle
label={
isShareInviteLinkEnabled
? t`Deactivate invite link`
: t`Create invite link`
}
isChecked={isShareInviteLinkEnabled}
onChange={handleInviteLinkToggle}
/>
)}
<div>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription ? (
<Button type="button" onClick={handleUpgrade}>
{t`Start 14 day free trial`}
{isFreePlan ? (
<Button
type="button"
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
>
{t`Choose plan`}
</Button>
) : (
<Button

View File

@@ -27,8 +27,8 @@ import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { getAvatarUrl } from "~/utils/helpers";
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
import { InviteMemberForm } from "./components/InviteMemberForm";
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
import { InviteMemberForm } from "./components/InviteMemberForm";
export default function MembersPage() {
const { modalContentType, openModal, isOpen } = useModal();
@@ -76,6 +76,19 @@ export default function MembersPage() {
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
const isProPlan =
!!proSubscription ||
workspace.plan === "pro" ||
workspace.plan === "enterprise";
const isTeamPlan = !!teamSubscription || workspace.plan === "team";
const isPaidPlan = isProPlan || isTeamPlan;
const activeMembers = data?.members.length ?? 0;
const totalSeats =
teamSubscription?.seats ??
proSubscription?.seats ??
(isPaidPlan ? null : 1);
const TableRow = ({
memberPublicId,
memberId,
@@ -144,8 +157,8 @@ export default function MembersPage() {
{memberName}
</p>
</div>
{((workspace.role === "admin" ||
data?.showEmailsToMembers === true) ||
{(workspace.role === "admin" ||
data?.showEmailsToMembers === true ||
showSkeleton) && (
<p
className={twMerge(
@@ -180,8 +193,7 @@ export default function MembersPage() {
<div className="relative inline-flex items-center">
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]">
{memberRole &&
memberRole.charAt(0).toUpperCase() +
memberRole.slice(1)}
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
{canEditMember && session?.user.id !== memberId && (
<HiChevronDown className="h-3 w-3" />
)}
@@ -264,38 +276,42 @@ export default function MembersPage() {
</h1>
</div>
<div className="flex items-center gap-3">
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && !!data && (
<>
{!proSubscription && !teamSubscription && (
{!isPaidPlan && (
<Link
href="/settings/workspace?upgrade=pro"
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
className="hidden items-center rounded-full border border-emerald-300 bg-emerald-50 px-3 py-1 text-center text-xs text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400 lg:flex"
>
<HiBolt />
<span className="ml-1 font-medium">
{t`Launch offer: Get unlimited members with Pro`}
</span>
<span className="ml-1 font-medium">{t`Upgrade`}</span>
</Link>
)}
<div
className={twMerge(
"flex items-center rounded-full border px-3 py-1 text-center text-xs",
teamSubscription || proSubscription
isPaidPlan
? "border-emerald-300 bg-emerald-50 text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
: "border-light-300 bg-light-50 text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900",
)}
>
<span className="font-medium">
{proSubscription
{isProPlan
? t`Pro Plan`
: teamSubscription
: isTeamPlan
? t`Team Plan`
: t`Free Plan`}
{proSubscription && unlimitedSeats && (
<span className="ml-1 text-xs"></span>
)}
</span>
</div>
{isPaidPlan && (unlimitedSeats || totalSeats !== null) && (
<div className="flex items-center rounded-full border border-light-300 bg-light-50 px-3 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<span className="font-medium">
{unlimitedSeats
? t`Unlimited seats`
: `${activeMembers}/${totalSeats} ${t`seats`}`}
</span>
</div>
)}
</>
)}
<Button
@@ -384,8 +400,6 @@ export default function MembersPage() {
isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
>
<InviteMemberForm
userId={session?.user.id}
numberOfMembers={data?.members.length ?? 1}
subscriptions={subscriptions}
unlimitedSeats={unlimitedSeats}
/>

View File

@@ -0,0 +1,320 @@
import { useRouter, useSearchParams } from "next/navigation";
import { Radio, RadioGroup } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { HiUser } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import { api } from "~/utils/api";
type PlanId = "solo" | "team" | "pro";
type Billing = "monthly" | "annual";
// Each user orbits via a zero-size pivot div at center (144,144) that rotates.
// The icon is offset from the pivot by `radius` pixels along the x-axis.
// A counter-rotation on the icon keeps it upright.
const ORBIT_USERS: Record<
PlanId,
{ id: string; angle: number; radius: number; duration: number }[]
> = {
solo: [],
team: [
{ id: "t1", angle: 45, radius: 104, duration: 50 },
{ id: "t2", angle: 170, radius: 104, duration: 50 },
{ id: "t3", angle: 290, radius: 104, duration: 50 },
],
pro: [
{ id: "t1", angle: 45, radius: 104, duration: 50 },
{ id: "t2", angle: 170, radius: 104, duration: 50 },
{ id: "t3", angle: 290, radius: 104, duration: 50 },
{ id: "p1", angle: 10, radius: 144, duration: 70 },
{ id: "p2", angle: 120, radius: 144, duration: 70 },
{ id: "p4", angle: 240, radius: 144, duration: 70 },
{ id: "p3", angle: 230, radius: 64, duration: 30 },
],
};
export default function SelectPlanView() {
const router = useRouter();
const searchParams = useSearchParams();
const [selected, setSelected] = useState<PlanId>(
(searchParams.get("plan") as PlanId | null) ?? "solo",
);
const [billing, setBilling] = useState<Billing>(
(searchParams.get("billing") as Billing | null) ?? "annual",
);
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const workspacePublicId = searchParams.get("workspacePublicId");
const { data: workspaces } = api.workspace.all.useQuery();
const { data: session } = authClient.useSession();
const { data: user } = api.user.getUser.useQuery(undefined, {
enabled: !!session?.user,
});
const userImage = user?.image ?? session?.user.image ?? null;
const hasExistingWorkspace = !!workspaces?.length;
const FREQUENCIES: { value: Billing; label: string }[] = [
{ value: "monthly", label: t`Monthly` },
{ value: "annual", label: t`Annual` },
];
const PLANS: {
id: PlanId;
name: string;
monthly: string;
annual: string;
description: string;
trial?: boolean;
}[] = [
{
id: "solo",
name: t`Solo`,
monthly: t`Free`,
annual: t`Free`,
description: t`Good for individuals starting out who just need the essentials.`,
},
{
id: "team",
name: t`Team`,
monthly: "$10/user/mo",
annual: "$8/user/mo",
description: t`Best for small teams who want to collaborate and move faster together.`,
trial: true,
},
{
id: "pro",
name: t`Pro`,
monthly: "$29/mo",
annual: "$23/mo",
description: t`Unlimited members and a custom workspace username for teams ready to scale.`,
trial: true,
},
];
const buildSelectPlanUrl = (plan: PlanId, b: Billing) => {
const base = `/onboarding/select-plan?plan=${plan}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`;
return workspacePublicId
? `${base}&workspacePublicId=${workspacePublicId}`
: base;
};
const handleSelectPlan = (plan: PlanId) => {
setSelected(plan);
router.replace(buildSelectPlanUrl(plan, billing));
};
const handleSetBilling = (b: Billing) => {
setBilling(b);
router.replace(buildSelectPlanUrl(selected, b));
};
const handleContinue = async () => {
if (workspacePublicId && selected !== "solo") {
const response = await fetch("/api/stripe/create_checkout_session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
plan: selected,
billing,
workspacePublicId,
successUrl: returnUrl,
cancelUrl: returnUrl,
}),
});
const { url } = (await response.json()) as { url: string };
if (url) window.location.href = url;
return;
}
router.push(
`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
);
};
const handleCancel = () => router.push(returnUrl);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-light-100 px-4 py-8 dark:bg-dark-50 md:px-6">
<div className="w-full max-w-3xl overflow-hidden rounded-xl border border-light-400 bg-light-200 shadow-xl dark:border-dark-400 dark:bg-dark-100">
<div className="flex flex-col md:h-[520px] md:flex-row">
{/* Left panel */}
<div className="flex flex-col p-6 md:w-[55%] md:p-8">
<div className="flex-1">
<h2 className="text-xl font-bold text-light-1000 dark:text-dark-1000">
{t`Choose a plan`}
</h2>
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
{t`Pick a plan to get started. All paid plans include a 14-day free trial.`}
</p>
{/* Billing toggle */}
<div className="mt-4 flex justify-end">
<RadioGroup
value={billing}
onChange={handleSetBilling}
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600"
>
{FREQUENCIES.map((f) => (
<Radio
key={f.value}
value={f.value}
className={twMerge(
"cursor-pointer rounded-full px-2.5 py-0.5 text-xs transition-colors",
billing === f.value
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
: "text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200",
)}
>
{f.label}
</Radio>
))}
</RadioGroup>
</div>
<div className="mt-4 space-y-3">
{PLANS.map((plan) => {
const badge =
billing === "annual" ? plan.annual : plan.monthly;
return (
<button
key={plan.id}
type="button"
onClick={() => handleSelectPlan(plan.id)}
className={`relative w-full rounded-lg border px-4 py-3 text-left transition-colors ${
selected === plan.id
? "border-light-700 bg-light-300 dark:border-dark-600 dark:bg-dark-200"
: "border-light-500 bg-light-200 hover:border-light-600 dark:border-dark-500 dark:bg-dark-100 dark:hover:border-dark-600"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 pr-8">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-light-1000 dark:text-dark-1000">
{plan.name}
</span>
<span className="rounded-full bg-neutral-700 px-2 py-px text-[11px] font-medium text-neutral-200">
{badge}
</span>
{plan.trial && billing === "annual" && (
<span className="rounded-full bg-emerald-500/10 px-2 py-px text-[11px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400">
-20%
</span>
)}
</div>
<p className="mt-1 text-xs text-light-800 dark:text-dark-800">
{plan.description}
</p>
</div>
<div className="mt-0.5 flex-shrink-0">
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
selected === plan.id
? "border-light-900 bg-light-900 dark:border-dark-900 dark:bg-dark-900"
: "border-light-700 dark:border-dark-700"
}`}
>
{selected === plan.id && (
<div className="h-1.5 w-1.5 rounded-full bg-light-100 dark:bg-dark-100" />
)}
</div>
</div>
</div>
</button>
);
})}
</div>
</div>
<div className="mt-6 flex justify-end gap-2 md:mt-8">
{hasExistingWorkspace && (
<Button variant="ghost" onClick={handleCancel}>
{t`Cancel`}
</Button>
)}
<Button onClick={() => void handleContinue()}>
{workspacePublicId && selected !== "solo"
? t`Upgrade`
: t`Continue`}
</Button>
</div>
</div>
{/* Right panel */}
<div className="hidden items-center justify-center bg-light-200 dark:bg-dark-200 md:flex md:w-[45%]">
<div className="relative h-72 w-72">
{/* Orbit rings */}
<div className="absolute inset-0 m-auto h-72 w-72 rounded-full border border-light-400 dark:border-dark-400" />
<div className="absolute inset-0 m-auto h-52 w-52 rounded-full border border-light-400 dark:border-dark-400" />
<div className="absolute inset-0 m-auto h-32 w-32 rounded-full border border-light-400 dark:border-dark-400" />
{/* Centre person */}
<div className="absolute inset-0 m-auto flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-light-300 dark:bg-dark-300">
{userImage ? (
<img
src={userImage}
alt={t`Your avatar`}
className="h-full w-full object-cover"
/>
) : (
<HiUser className="h-7 w-7 text-light-700 dark:text-dark-700" />
)}
</div>
{/* Orbiting users — zero-size pivot at center rotates; icon offset by radius stays on the ring */}
<AnimatePresence>
{ORBIT_USERS[selected].map((u) => (
<motion.div
key={u.id}
style={{ position: "absolute", left: 144, top: 144 }}
initial={{ opacity: 0, rotate: u.angle }}
animate={{ opacity: 1, rotate: u.angle + 360 }}
exit={{ opacity: 0 }}
transition={{
opacity: { duration: 0.3 },
rotate: {
duration: u.duration,
repeat: Infinity,
ease: "linear",
repeatType: "loop",
},
}}
>
{/* Counter-rotate icon so it stays upright */}
<motion.div
style={{
position: "absolute",
left: u.radius - 12,
top: -12,
}}
initial={{ rotate: -u.angle }}
animate={{ rotate: -(u.angle + 360) }}
transition={{
duration: u.duration,
repeat: Infinity,
ease: "linear",
repeatType: "loop",
}}
className="flex h-6 w-6 items-center justify-center rounded-full bg-light-400 dark:bg-dark-400"
>
<HiUser className="h-3.5 w-3.5 text-light-800 dark:text-dark-800" />
</motion.div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</div>
</div>
{!hasExistingWorkspace && (
<Button variant="ghost" onClick={() => authClient.signOut()}>
{t`Sign out`}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,389 @@
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
import {
HiArrowLeft,
HiArrowPath,
HiArrowRight,
HiCheck,
HiEllipsisVertical,
HiInformationCircle,
HiLockClosed,
} from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import Input from "~/components/Input";
import LoadingSpinner from "~/components/LoadingSpinner";
import Toggle from "~/components/Toggle";
import { Tooltip } from "~/components/Tooltip";
import { useDebounce } from "~/hooks/useDebounce";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.slice(0, 60);
}
export default function WorkspaceNameView() {
const router = useRouter();
const searchParams = useSearchParams();
const plan = searchParams.get("plan") ?? "solo";
const billing = searchParams.get("billing") ?? "annual";
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const licenseKeyParam = searchParams.get("license_key");
const isLicenseFlow = !!licenseKeyParam;
const { showPopup } = usePopup();
useEffect(() => {
if (licenseKeyParam) {
localStorage.setItem("partnerLicenseKey", licenseKeyParam);
}
}, [licenseKeyParam]);
const [isProToggle, setIsProToggle] = useState(plan === "pro");
const effectivePlan = isProToggle ? "pro" : plan;
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
const [description, setDescription] = useState("");
const handleNameChange = (value: string) => {
setName(value);
if (isProToggle && !slugManuallyEdited) {
setSlug(slugify(value));
}
};
const [debouncedSlug] = useDebounce(slug, 500);
const isTyping = slug !== debouncedSlug;
const slugAvailability = api.workspace.checkSlugAvailability.useQuery(
{ workspaceSlug: debouncedSlug },
{ enabled: isProToggle && debouncedSlug.length >= 3 && !isTyping },
);
const isSlugAvailable =
slugAvailability.data?.isAvailable && !slugAvailability.data?.isReserved;
const isSlugTaken =
slugAvailability.data?.isAvailable === false &&
!slugAvailability.data?.isReserved;
const isSlugReserved = slugAvailability.data?.isReserved === true;
const slugError = isSlugTaken
? t`This URL has already been taken`
: isSlugReserved
? t`This URL is reserved`
: undefined;
const { data: session } = authClient.useSession();
const { data: user } = api.user.getUser.useQuery(undefined, {
enabled: !!session?.user,
});
const { data: workspaces } = api.workspace.all.useQuery();
const hasExistingWorkspace = !!workspaces?.length;
const utils = api.useUtils();
const previewSlug = isProToggle
? slugify(slug) || slugify(name) || "your-workspace"
: "your-workspace";
const createWorkspace = api.workspace.create.useMutation({
onSuccess: async (workspace) => {
if (!workspace.publicId) return;
localStorage.setItem("workspacePublicId", workspace.publicId);
void utils.workspace.all.invalidate();
const storedLicenseKey = localStorage.getItem("partnerLicenseKey");
if (storedLicenseKey) {
localStorage.removeItem("partnerLicenseKey");
router.push(
`/api/partner/link?license_key=${encodeURIComponent(storedLicenseKey)}`,
);
} else {
router.push("/boards");
}
},
onError: () => {
showPopup({
header: t`Unable to create workspace`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const [isRedirectingToCheckout, setIsRedirectingToCheckout] = useState(false);
const handleContinue = async () => {
if (!name.trim()) return;
if (effectivePlan === "solo" || isLicenseFlow) {
createWorkspace.mutate({
name: name.trim(),
...(description.trim() && { description: description.trim() }),
});
return;
}
// team/pro: redirect to Stripe — workspace created on checkout_success
setIsRedirectingToCheckout(true);
try {
const response = await fetch("/api/stripe/create_checkout_session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceName: name.trim(),
...(description.trim() && {
workspaceDescription: description.trim(),
}),
...(effectivePlan === "pro" && slug ? { workspaceSlug: slug } : {}),
cancelUrl: `${window.location.pathname}?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
successUrl: "/boards",
billing,
plan: effectivePlan,
}),
});
const data = await response.json();
const url = (data as { url: string }).url;
if (url) {
window.location.href = url;
return;
}
} catch {
// fall through
}
setIsRedirectingToCheckout(false);
showPopup({
header: t`Unable to start checkout`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
};
useEffect(() => {
document.getElementById("workspace-name-input")?.focus();
}, []);
const displayName = user?.name ?? session?.user.name ?? "";
const BOARDS = [t`Roadmap`, t`Engineering`, t`Marketing`];
const [visibleCount, setVisibleCount] = useState(1);
useEffect(() => {
const timers = BOARDS.slice(1).map((_, i) =>
setTimeout(() => setVisibleCount(i + 2), (i + 1) * 2000),
);
return () => timers.forEach(clearTimeout);
}, []);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-light-100 px-4 py-8 dark:bg-dark-50 md:px-6">
<div className="w-full max-w-3xl overflow-hidden rounded-xl border border-light-400 bg-light-200 shadow-xl dark:border-dark-400 dark:bg-dark-100">
<div className="flex flex-col md:h-[520px] md:flex-row">
{/* Left panel */}
<div className="flex flex-col p-6 md:w-[55%] md:p-8">
<div className="flex-1">
<h2 className="text-xl font-bold text-light-1000 dark:text-dark-1000">
{t`Set up your workspace`}
</h2>
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
{t`You can always change these later in settings.`}
</p>
<div className="mt-6 space-y-3">
<Input
id="workspace-name-input"
placeholder={t`Workspace name`}
value={name}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleContinue()}
maxLength={64}
/>
{!isLicenseFlow && (
<div className="space-y-2">
<Input
placeholder={t`your-workspace`}
value={isProToggle ? slug : t`your-workspace`}
onChange={(e) => {
setSlugManuallyEdited(true);
setSlug(
e.target.value
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.slice(0, 60),
);
}}
disabled={!isProToggle}
prefix="kan.bn/"
className={
!isProToggle ? "cursor-not-allowed opacity-50" : ""
}
errorMessage={slugError}
iconRight={
!isProToggle ? (
<Tooltip
content={
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
}
placement="top"
delay={0}
>
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
</Tooltip>
) : isProToggle && slug.length >= 3 ? (
isTyping || slugAvailability.isPending ? (
<LoadingSpinner />
) : isSlugAvailable ? (
<HiCheck className="h-4 w-4 text-white" />
) : null
) : null
}
/>
</div>
)}
{!isLicenseFlow && plan !== "pro" && (
<div className="pb-2">
<Toggle
isChecked={isProToggle}
onChange={() => setIsProToggle((v) => !v)}
label={t`Upgrade to Pro ($29/month)`}
labelPosition="after"
/>
</div>
)}
<div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t`Workspace description`}
maxLength={280}
rows={3}
className="block w-full resize-none 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"
/>
<p className="mt-1 text-right text-[10px] text-light-700 dark:text-dark-700">
{description.length}/280
</p>
</div>
</div>
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
<div className="ml-auto flex gap-2">
{!isLicenseFlow && (
<Button
variant="ghost"
onClick={() =>
router.replace(
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
)
}
>
{t`Back`}
</Button>
)}
<Button
onClick={() => void handleContinue()}
disabled={
!name.trim() ||
createWorkspace.isPending ||
isRedirectingToCheckout ||
(isProToggle &&
slug.length >= 3 &&
(isTyping || slugAvailability.isPending || !!slugError))
}
isLoading={
createWorkspace.isPending || isRedirectingToCheckout
}
>
{t`Continue`}
</Button>
</div>
</div>
</div>
{/* Right panel — browser mockup */}
<div className="hidden flex-col bg-light-300 dark:bg-dark-200 md:flex md:w-[45%]">
{/* Browser chrome */}
<div className="flex items-center gap-2 border-b border-light-400 px-3 py-2.5 dark:border-dark-400">
<div className="flex items-center gap-1.5 text-light-700 dark:text-dark-700">
<HiArrowLeft className="h-3.5 w-3.5" />
<HiArrowRight className="h-3.5 w-3.5" />
<HiArrowPath className="h-3.5 w-3.5" />
</div>
<div className="flex flex-1 items-center gap-1.5 rounded-md bg-light-200 px-2.5 py-1 dark:bg-dark-300">
<HiLockClosed className="h-3 w-3 flex-shrink-0 text-light-700 dark:text-dark-700" />
<span className="truncate text-xs text-light-900 dark:text-dark-900">
kan.bn/
<span className="text-light-1000 dark:text-dark-1000">
{previewSlug}
</span>
</span>
</div>
<HiEllipsisVertical className="h-4 w-4 flex-shrink-0 text-light-700 dark:text-dark-700" />
</div>
{/* Browser content */}
<div className="flex flex-1 flex-col items-center overflow-hidden px-14 py-8">
{/* Workspace name + description */}
<p className="text-center text-xs font-bold text-light-1000 dark:text-dark-1000">
{name || t`Your workspace`}
</p>
<p className="mt-0.5 line-clamp-2 break-all text-center text-[10px] text-light-800 dark:text-dark-800">
{description || t`Your workspace description`}
</p>
{/* Boards area */}
<div className="mt-3 flex w-full flex-1 flex-col rounded-lg bg-light-200 p-2 dark:bg-dark-300">
<div className="flex flex-1 flex-col gap-1.5">
{BOARDS.map((board, i) => (
<motion.div
key={board}
initial={{ opacity: 0, y: -12 }}
animate={
i < visibleCount
? { opacity: 1, y: 0 }
: { opacity: 0, y: -12 }
}
transition={{
opacity: { duration: 0.2 },
y: { duration: 0.25, ease: "easeOut" },
}}
className="flex w-full flex-1 items-center justify-center rounded border border-dashed border-light-400 bg-light-50 px-3 text-[10px] font-medium text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900"
>
{board}
</motion.div>
))}
</div>
</div>
{/* Footer */}
<p className="mt-2 text-[10px] font-semibold text-light-900 dark:text-dark-900">
kan.bn
</p>
</div>
</div>
</div>
</div>
{!hasExistingWorkspace && (
<Button variant="ghost" onClick={() => authClient.signOut()}>
{t`Sign out`}
</Button>
)}
</div>
);
}

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 = () => {
@@ -129,6 +140,11 @@ export function CardModal({
</div>
) : (
<>
{data?.cardNumber != null && data.list.board.workspace.cardPrefix && (
<span className="mb-1 block text-xs font-medium text-light-700 dark:text-dark-800">
{data.list.board.workspace.cardPrefix}-{data.cardNumber}
</span>
)}
<h1 className="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{data?.title}
</h1>

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