Compare commits

...

78 Commits

Author SHA1 Message Date
Henry
f18021e560 fix: correct template creation condition in BoardDropdown 2026-06-30 11:59:00 +01:00
Henry
a994c061ac fix: prevent cross-tenant comment deletion and edit 2026-06-25 17:11:08 +01:00
Henry
6e1d821b35 fix: list dnd when board exceeds viewport width (#526) 2026-06-25 17:00:07 +01:00
Henry
120504ab58 feat: tweak mobile list snapping 2026-06-25 16:38:13 +01:00
Max K.
ebb19584a2 feat: add mobile list snap scrolling (#510) 2026-06-25 16:05:27 +01:00
hjball
410f4c481e chore: update translations 2026-06-25 15:01:34 +00:00
JayDataEngineer
9d7a82d381 fix: sync Editor editable state with readOnly prop (#523)
The rich-text Editor (used for card descriptions) creates its Tiptap
instance once via useEditor with an empty dependency array. This means
the initial value of `editable: !readOnly` is captured at mount time
and never updated, and the onChange/onBlur callbacks are frozen as the
first-render closures.

In CardPage, the description Editor mounts as soon as the card query
resolves but before the permissions query resolves, so `readOnly` is
`true` and onChange/onBlur are `undefined` at that moment. When
permissions resolve a moment later and `canEdit` flips to `true`,
the Editor never becomes editable — leaving the description stuck
read-only even for workspace admins.

Fix mirrors the existing pattern in PlainTextEditor.tsx:
- Use refs for onChange/onBlur so the editor reads the latest values
- Add a useEffect that calls editor.setEditable(!readOnly) when the
  readOnly prop changes

Co-authored-by: Jay <CodeEngineering@pm.me>
2026-06-25 16:00:13 +01:00
hjball
f369ebeaa8 chore: compile translations 2026-06-11 15:45:43 +00:00
hajorappe
0fbe198fa2 fix: improve German translations in /de/messages.json (#514)
Improved German translations for better readability.
2026-06-11 16:44:09 +01:00
Morfixx
81b67df9e3 feat: kan mcp server (#485)
* feat: kan mcp server initial attempt

* fix: card was missing options and added default fallbacks

* fix: label creating with better information for colors and presets
2026-06-10 09:10:22 +01:00
Henry
4f3b49716d chore: improve contribution guidelines and add PR template 2026-06-09 15:30:59 +01:00
Henry
1347cc562c fix: encode attachment filename header to support non-ASCII characters (#519) 2026-06-09 12:16:51 +01:00
Henry
2893e95597 fix(cloud): free partner subscription slot when workspace is deleted 2026-05-31 21:21:36 +01:00
Henry
89654c20b0 fix(partner): scope auto-claim to owned workspaces and guard against active subs 2026-05-30 12:51:21 +01:00
hjball
4442e67832 chore: update translations 2026-05-29 22:53:24 +00:00
Henry
d7e8b5ae6b feat: reset checklist items to default when duplicating 2026-05-29 23:51:48 +01:00
Henry
03e1e19464 fix: ensure upgrade/downgrade paths for migrate requests 2026-05-29 23:48:01 +01:00
hjball
a3353acf44 chore: compile translations 2026-05-29 22:29:11 +00:00
hjball
a4a807ed1d chore: update translations 2026-05-29 22:29:06 +00:00
Henry
6b8ada8d90 feat: duplicate card via dropdown 2026-05-29 23:27:21 +01:00
hjball
22899cd14c chore: update translations 2026-05-29 21:59:33 +00:00
Henry
833c69a73e cloud: enable workspace slots for partners 2026-05-29 22:58:09 +01:00
hjball
d9cf363302 chore: update translations 2026-05-25 21:01:17 +00:00
Henry
9212b1ca2e fix: adjust layout of card prefix on public modal 2026-05-25 21:59:57 +01:00
hjball
ede2475796 chore: update translations 2026-05-25 20:49:46 +00:00
Henry
e32ad1cd32 fix(cloud): hide empty notice box for pro subscriptions 2026-05-25 21:48:27 +01:00
hjball
bac22021ad chore: update translations 2026-05-21 21:53:34 +00:00
Henry
8d8cfa3bae feat(cloud): pause members when subscription is cancelled 2026-05-21 22:52:17 +01:00
hjball
971348a397 chore: compile translations 2026-05-21 20:39:43 +00:00
hjball
d3920cd246 chore: update translations 2026-05-21 20:39:39 +00:00
Henry
5af375825b fix: reliably select workspace on login (#507) 2026-05-21 21:38:09 +01:00
Henry
861416a18e feat(cloud): set seat limit checks for partner member invitations (#506) 2026-05-21 21:36:57 +01:00
Henry
a7e78ad580 fix: respect redirect param on login and signup pages (#504) 2026-05-21 21:36:02 +01:00
Henry
f288047a6a fix: run web container as non-root user (UID 1000) (#501) 2026-05-21 21:35:47 +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
126 changed files with 22474 additions and 3064 deletions

View File

@@ -23,8 +23,8 @@ 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=

View File

@@ -7,6 +7,9 @@ assignees: ''
---
> [!IMPORTANT]
> Wait for a maintainer to approve this issue before opening a PR. We'll signal approval with a comment or label. Feature PRs without an approved issue will be closed.
## ✨ Feature Request
**Is your feature request related to a problem? Please describe.**

24
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,24 @@
## Description
<!-- What does this PR do? Why? -->
## Type of change
- [ ] Bug fix
- [ ] Feature (requires an approved issue — see below)
- [ ] Refactor / chore
- [ ] Documentation
## Checklist
- [ ] I have linked the related issue below
- [ ] My code follows the existing style and conventions
- [ ] I have tested my changes locally
- [ ] I have included screenshots for any UI changes
## Linked issue
Closes #<!-- issue number -->
> **Feature PRs without a linked, approved issue will be closed without review.**
> Open or comment on a [feature request](https://github.com/kanbn/kan/issues/new?template=feature_request.md) first and wait for maintainer approval before building.

View File

@@ -1,52 +1,54 @@
# Contributing to Kan
Thank you for your interest in contributing to Kan! This document provides guidelines and instructions for contributing to the project.
Thank you for your interest in contributing to Kan!
## Getting Started
## Before you start
**Open an issue before writing code for a new feature.**
Feature PRs opened without a prior approved issue will be closed without review - not because the idea is bad, but because we can't review code for features we haven't aligned on yet. This protects your time as much as ours.
The process is:
1. Open a [feature request](https://github.com/kanbn/kan/issues/new?template=feature_request.md) and describe what you want to build
2. Wait for a maintainer to respond and signal approval (we aim to respond within a few days)
3. Once approved, open a PR that links the issue
Bug fixes and documentation improvements don't need prior approval - just open a PR.
---
## Getting started
1. Fork the repository
2. Clone your fork: `git clone https://github.com/YOUR-USERNAME/kan.git`
3. Create a new branch: `git checkout -b feat/your-feature-name`
3. Create a branch: `git checkout -b feat/your-feature-name` or `fix/your-fix-name`
4. Install dependencies: `pnpm install`
5. Copy `.env.example` to `.env` and configure your environment variables
6. Make your changes
7. Commit your changes: `git commit -m "feat: description of changes"`
8. Push to your fork: `git push origin feat/your-feature-name`
9. Open a Pull Request
7. Commit using [conventional commits](https://www.conventionalcommits.org/): `git commit -m "feat: description"`
8. Push and open a Pull Request
## Development Guidelines
## Pull request expectations
### Code Style
- Link the related issue (required for features)
- Write a clear description of what changed and why
- Include screenshots for any UI changes
- Keep PRs focused - one feature or fix per PR
- All CI checks must pass before review
- Follow the existing code style
- Use meaningful variable and function names
- Add comments for complex logic
## Code style
- Follow the existing patterns in the codebase
- Use meaningful names - avoid comments that just restate what the code does
- Keep functions focused and concise
### Commits
## Need help?
- Use clear and descriptive commit messages
- Reference issue numbers when applicable
- Keep commits focused on single changes
### Pull Requests
- Provide a clear description of the changes
- Include screenshots for UI changes
- Keep PRs focused on a single feature/fix
## Need Help?
- Join our [Discord server](https://discord.gg/e6ejRb6CmT) for questions
- Check existing issues and pull requests
- Join our [Discord server](https://discord.gg/e6ejRb6CmT)
- Check existing issues and PRs before opening new ones
- Email [henry@kan.bn](mailto:henry@kan.bn) for major concerns
## Code of Conduct
Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
We aim to foster an inclusive and welcoming community. Harassment and abusive behavior will not be tolerated.
## License
By contributing to Kan, you agree that your contributions will be licensed under the AGPLv3 License.

221
README.md
View File

@@ -170,53 +170,180 @@ pnpm dev
## Environment Variables 🔐
| Variable | Description | Required | Example |
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | No | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (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` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `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` |
| Variable | Description | Required | Example |
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | No | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (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` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `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.
## MCP Server (AI Control) 🤖
Kan ships with a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets any MCP-compatible AI client — GitHub Copilot, Claude Desktop, Cursor, Codex, and others — read and control your Kan instance using natural language.
### Prerequisites
- Node.js 18+
- A running Kan instance (self-hosted or cloud)
- A Kan API key (Settings → API Keys → Create key)
### Installation
You do **not** need to clone this repository. The recommended way is to use `npx`, which runs the server on-demand and always uses the latest version — no global install required:
```bash
npx -y @kan/mcp
```
Alternatively, install it globally:
```bash
npm install -g @kan/mcp
kan-mcp
```
### Configuration
The server is configured via two environment variables:
| Variable | Description | Example |
| --------------- | ----------------------------------- | ------------------------------ |
| `KAN_BASE_URL` | Base URL of your Kan instance | `https://your-kan.example.com` |
| `KAN_API_TOKEN` | API key from your Kan user settings | `kan_xxxxxxxxxxxx` |
#### GitHub Copilot (VS Code)
Add the following to your VS Code `mcp.json` (open it via **MCP: Open User MCP Configuration** from the Command Palette):
```json
{
"servers": {
"kan": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@kan/mcp"],
"env": {
"KAN_BASE_URL": "https://your-kan-instance.com",
"KAN_API_TOKEN": "kan_your_api_key_here"
}
}
}
}
```
Then use Copilot in **Agent mode** to interact with Kan.
#### Claude Desktop
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"kan": {
"command": "npx",
"args": ["-y", "@kan/mcp"],
"env": {
"KAN_BASE_URL": "https://your-kan-instance.com",
"KAN_API_TOKEN": "kan_your_api_key_here"
}
}
}
}
```
#### Cursor / Codex / other clients
Use the same `command` + `args` + `env` pattern above — all MCP stdio clients follow the same format.
### Example prompts
Once connected, you can ask your AI assistant things like:
**Browsing**
- _"List all my workspaces"_
- _"Show me all boards in the Marketing workspace"_
- _"What cards are in the Backlog list of the Q3 Planning board?"_
- _"Get the full details of card X including comments and checklists"_
**Managing cards**
- _"Create a card called 'Fix login bug' in the To Do list of the Dev board"_
- _"Move the 'API redesign' card to the In Progress list"_
- _"Set a due date of next Friday on the 'Write docs' card"_
- _"Add a comment to the 'Deploy to prod' card saying the deployment is blocked"_
- _"Duplicate the 'Sprint template' card into the new Sprint 4 list"_
- _"Mark the 'Setup CI' checklist item as complete"_
**Organisation**
- _"Add the 'urgent' label to all cards assigned to me in the Backend board"_
- _"Create a 'Release checklist' checklist on the v2.0 card with items: smoke test, update changelog, tag release"_
- _"What tasks are assigned to @alice in the Mechanics Rework board?"_
**Workspace management**
- _"Create a new workspace called 'Client Projects'"_
- _"Invite bob@example.com to the Marketing workspace as a member"_
- _"Create a new board called 'Sprint 5' in the Dev workspace with lists: Backlog, In Progress, Done"_
- _"Search for anything related to 'authentication' across the Dev workspace"_
### Available tools
The MCP server exposes 46 tools across 7 resource types:
| Resource | Tools |
| ----------------- | ------------------------------------------------------------------- |
| Workspaces | list, find by name, get, create, update, delete, search, check slug |
| Boards | list, find by name, get, get by slug, create, update, delete |
| Lists | create, update, delete |
| Cards | create, get, update, delete, duplicate, get activities |
| Card interactions | add/update/delete comment, toggle label, toggle member |
| Checklists | create, update, delete, create item, update item, delete item |
| Labels | get, create, update, delete |
| Members | invite, remove, update role, manage invite links |
## Contributing 🤝
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
@@ -227,6 +354,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,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
@@ -109,13 +110,15 @@ ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Copy the standalone Next.js server
COPY --from=builder /app/apps/web/.next/standalone/ ./
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/standalone/ ./
# Copy static assets and public files
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
COPY --chown=1000:1000 --from=builder /app/apps/web/public/ ./apps/web/public/
# Copy bootstrap script for runtime env var injection
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
COPY --chown=1000:1000 apps/web/bootstrap.cjs ./bootstrap.cjs
USER 1000:1000
EXPOSE 3000
CMD ["bootstrap.cjs"]

View File

@@ -88,6 +88,12 @@ checksums:
7L01XJ/message: c46571856723b03262fd33f511116298
7L01XJ/origin/0/0: feacdaf2791aaddec128f463ffa2e807
7L01XJ/translation: c46571856723b03262fd33f511116298
JEt3q7/message: 233710e683076c7eb6400a5c5ddd1d2a
JEt3q7/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
JEt3q7/translation: 233710e683076c7eb6400a5c5ddd1d2a
mkehy7/message: 71c4ebc206483ae49a2bd3dd7b6038ba
mkehy7/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
mkehy7/translation: 71c4ebc206483ae49a2bd3dd7b6038ba
F6pfE9/message: 3e1ec025c4a50830bbb9ad57a176630a
F6pfE9/origin/0/0: 4b563046ba98abb3777fb1c3560866aa
F6pfE9/origin/1/0: f64cd62b620f454998523af2a4330278
@@ -224,7 +230,8 @@ checksums:
Z3krJD/origin/0/0: e9da4b3a0eedd68ed126437194a482fe
Z3krJD/translation: 0077436d9f37bfd64f3ae076a5a05040
WnEwDO/message: 2959fd276248208b65cb27ed46b20135
WnEwDO/origin/0/0: d6f5120f072a821219608624f361384e
WnEwDO/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
WnEwDO/origin/1/0: d6f5120f072a821219608624f361384e
WnEwDO/translation: 2959fd276248208b65cb27ed46b20135
j1%2BHPc/message: baf4c3cd580291f41458f9ed28102c49
j1%2BHPc/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
@@ -548,7 +555,11 @@ checksums:
kryGs%2B/translation: bba0beaced7ea954ceb980f2b022ffee
sU2uWc/message: 3fe52244676faf64e5266b6713d01e07
sU2uWc/origin/0/0: 97bd206a05414e2a571020d64e166093
sU2uWc/origin/1/0: 1b05beba6bdd908015fa8e798717f024
sU2uWc/translation: 3fe52244676faf64e5266b6713d01e07
9trBXW/message: 0035c67fb6f70992effb263b0b30e6d4
9trBXW/origin/0/0: 1b05beba6bdd908015fa8e798717f024
9trBXW/translation: 0035c67fb6f70992effb263b0b30e6d4
fEY2vP/message: 91509e2f92b0b3b11330b6983139fdbf
fEY2vP/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
fEY2vP/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
@@ -582,8 +593,9 @@ checksums:
pFGfsR/origin/0/0: 82383b95848d24b9311ceaef0ec94bb6
pFGfsR/translation: b0d0be4f63c16552b3aa795af29240d1
5IXWmO/message: e9a430fcd298def74212238df0f680d6
5IXWmO/origin/0/0: 4a1be3707d14c696a16f453d132fb248
5IXWmO/origin/1/0: d6f5120f072a821219608624f361384e
5IXWmO/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
5IXWmO/origin/1/0: 4a1be3707d14c696a16f453d132fb248
5IXWmO/origin/2/0: d6f5120f072a821219608624f361384e
5IXWmO/translation: e9a430fcd298def74212238df0f680d6
GnRmu4/message: 5eb5de823f7ca5a4d97bb41e6a3f675a
GnRmu4/origin/0/0: a2dd1fc876bd42b9db7ca7aaa57ac5e2
@@ -595,6 +607,10 @@ checksums:
zMquA7/message: 38ff8d574865bb71d2f620bc4ba98c20
zMquA7/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
5EMoSo/message: d5825bc247cfd3e52e233f7dab8051c6
5EMoSo/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
5EMoSo/origin/1/0: 619b4be43b9080346366c4bf6ba2253d
5EMoSo/translation: d5825bc247cfd3e52e233f7dab8051c6
VHS0aJ/message: 31d0962985c83a29558c98a765bb1b16
VHS0aJ/origin/0/0: 15792234f408027822ee71ccddde6f9b
VHS0aJ/translation: 31d0962985c83a29558c98a765bb1b16
@@ -614,9 +630,10 @@ checksums:
RRn9jf/origin/1/0: d6f5120f072a821219608624f361384e
RRn9jf/translation: 210b6ff8727f976182ec3f29ea3c7667
yz7wBu/message: 2c2e22f8424a1031de89063bd0022e16
yz7wBu/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
yz7wBu/origin/1/0: afee530613110e248ffde6b56872ebed
yz7wBu/origin/2/0: dcafca531d4dce1faef7788feabfb048
yz7wBu/origin/0/0: 278a40eb45b1219f424703d0abc1d273
yz7wBu/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
yz7wBu/origin/2/0: afee530613110e248ffde6b56872ebed
yz7wBu/origin/3/0: dcafca531d4dce1faef7788feabfb048
yz7wBu/translation: 2c2e22f8424a1031de89063bd0022e16
TcXcmk/message: a2da6c2339301e7c3ddf068c4ff9f7e8
TcXcmk/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
@@ -734,6 +751,9 @@ checksums:
uvH2xS/message: ecc2617544a6d385d18678577fa112fd
uvH2xS/origin/0/0: 97bd206a05414e2a571020d64e166093
uvH2xS/translation: ecc2617544a6d385d18678577fa112fd
7mrkyO/message: d3853b58e9b736eefb0f9bf400daf4d7
7mrkyO/origin/0/0: 1b05beba6bdd908015fa8e798717f024
7mrkyO/translation: d3853b58e9b736eefb0f9bf400daf4d7
9PaIxv/message: da95932e7a1465a5d21aa3b855a46dc2
9PaIxv/origin/0/0: c0b59968b2b234167c488a05cc38710b
9PaIxv/translation: da95932e7a1465a5d21aa3b855a46dc2
@@ -993,6 +1013,7 @@ checksums:
BTDPLo/message: f6a33afcf5ff1489551c94867e083dca
BTDPLo/origin/0/0: 97bd206a05414e2a571020d64e166093
BTDPLo/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
BTDPLo/origin/2/0: 1b05beba6bdd908015fa8e798717f024
BTDPLo/translation: f6a33afcf5ff1489551c94867e083dca
06EQqT/message: e9f56489fb395a890ae2557200c29a36
06EQqT/origin/0/0: 97bd206a05414e2a571020d64e166093
@@ -1118,6 +1139,9 @@ checksums:
rarRW%2F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
rarRW%2F/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
rarRW%2F/translation: 91553b606a6ebed3534cd2e5635ce004
sZixXc/message: db4e3e372abb1ac510c62b14d544f0f8
sZixXc/origin/0/0: 0627e0040ca4939c9a57349e98c51797
sZixXc/translation: db4e3e372abb1ac510c62b14d544f0f8
sVBn4K/message: 0d7979a25d290123ed8b8906bf468f1a
sVBn4K/origin/0/0: d465da67c0a870d198eb7f313569c137
sVBn4K/translation: 0d7979a25d290123ed8b8906bf468f1a
@@ -1131,8 +1155,7 @@ checksums:
x0kMO%2F/origin/0/0: 30791e26a172d47d3cb0693e82679246
x0kMO%2F/translation: 9da6c012e1b512d8c7fbd812626c4675
YaZDYI/message: 57cc47581c7faaffb3b1721b6352b576
YaZDYI/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
YaZDYI/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
YaZDYI/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
YaZDYI/translation: 57cc47581c7faaffb3b1721b6352b576
lsgnzd/message: 934c6867d9bdbe6099b2c0f5a3107f78
lsgnzd/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
@@ -1241,6 +1264,9 @@ checksums:
2POOFK/origin/4/0: 9737a9cef0646ff4bbd942b1924b9a2c
2POOFK/origin/5/0: fadd5a5b6963ec5f261072f7e8d2140b
2POOFK/translation: 0326365539c004f6088656f692602078
8LLt89/message: 9a78178dc1874543d35153ae325e2811
8LLt89/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
8LLt89/translation: 9a78178dc1874543d35153ae325e2811
uP4V6I/message: 2d403501f0488ec7d4eac979faf051a2
uP4V6I/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
uP4V6I/translation: 2d403501f0488ec7d4eac979faf051a2
@@ -1377,6 +1403,9 @@ checksums:
2Py5S%2F/message: fe1183a7439299210d8ab7379b8f4d01
2Py5S%2F/origin/0/0: 83911e3eacbad4583e2b1647a784d154
2Py5S%2F/translation: fe1183a7439299210d8ab7379b8f4d01
VOwhhz/message: 613f43526ea128b2749f70878f10008c
VOwhhz/origin/0/0: 1b05beba6bdd908015fa8e798717f024
VOwhhz/translation: 613f43526ea128b2749f70878f10008c
iwr7AP/message: 27c24b56895defecd10b0171b39bc93f
iwr7AP/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
iwr7AP/translation: 27c24b56895defecd10b0171b39bc93f
@@ -1461,6 +1490,9 @@ checksums:
"%2BdjOzj/origin/0/0": faa852a0c0e3c81fe948e394029c1ba5
"%2BdjOzj/origin/1/0": 8628ddb6f4ded25c0f17e2cbda8207d3
"%2BdjOzj/translation": ade922db1be6b26bc979565ce5de2bc7
M2zwdn/message: 4c9c0d43434ad63a70e8f956d1900602
M2zwdn/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
M2zwdn/translation: 4c9c0d43434ad63a70e8f956d1900602
qPP4lD/message: 03eeed2d715e770259f722ba48a61ab3
qPP4lD/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
qPP4lD/translation: 03eeed2d715e770259f722ba48a61ab3
@@ -1527,6 +1559,12 @@ checksums:
iQmbPb/message: ac2bcac75151e2fcd9eb768cea275a77
iQmbPb/origin/0/0: 98e2302d0d8d12ce65c4140b87450673
iQmbPb/translation: ac2bcac75151e2fcd9eb768cea275a77
aDzbuM/message: b6cb6ee4c4dd2cbe0d5d6a0088dcd731
aDzbuM/origin/0/0: 278a40eb45b1219f424703d0abc1d273
aDzbuM/translation: b6cb6ee4c4dd2cbe0d5d6a0088dcd731
K3LUJr/message: 6dd9358a22160ebed51ab3f36e2bb0b1
K3LUJr/origin/0/0: 278a40eb45b1219f424703d0abc1d273
K3LUJr/translation: 6dd9358a22160ebed51ab3f36e2bb0b1
1njn7W/message: bb78ea2c6edd8662676c81a8a13ecb93
1njn7W/origin/0/0: 358ea1b5babb2e9ccbf1a4c9f89dd3a5
1njn7W/translation: bb78ea2c6edd8662676c81a8a13ecb93
@@ -1600,11 +1638,17 @@ checksums:
W%2FElkg/message: 1f527cd6d1ed602930bcaa303f503b51
W%2FElkg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
W%2FElkg/translation: 1f527cd6d1ed602930bcaa303f503b51
NwiNTb/message: 9840dd63c40b860a9de1488a07b86828
NwiNTb/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
NwiNTb/translation: 9840dd63c40b860a9de1488a07b86828
OvoEq7/message: 1606dc30b369856b9dba1fe9aec425d2
OvoEq7/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
OvoEq7/origin/1/0: 1d6b5f88cdad160a67842f6b65387448
OvoEq7/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
OvoEq7/translation: 1606dc30b369856b9dba1fe9aec425d2
GnG6Oy/message: c7910e9020804dbe5c7022044c669e68
GnG6Oy/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
GnG6Oy/translation: c7910e9020804dbe5c7022044c669e68
wlQNTg/message: 0932e80cba1e3e0a7f52bb67ff31da32
wlQNTg/origin/0/0: 94d3e20c29bb517dd394711d7524e478
wlQNTg/origin/1/0: db1e755dc7d73e037f425111bf7c30a6
@@ -1737,6 +1781,9 @@ checksums:
TXO5TM/message: 7f1ed5d777cade7d62303e9e591bbf63
TXO5TM/origin/0/0: feacdaf2791aaddec128f463ffa2e807
TXO5TM/translation: 7f1ed5d777cade7d62303e9e591bbf63
HOMO91/message: 8a7b468d8a18e30de7743e4d5841c87f
HOMO91/origin/0/0: 278a40eb45b1219f424703d0abc1d273
HOMO91/translation: 8a7b468d8a18e30de7743e4d5841c87f
hXMFoN/message: cedf633d99c77ff4356e089f2d98c0a6
hXMFoN/origin/0/0: fc0c18a095a68277a0f90a115942683b
hXMFoN/translation: cedf633d99c77ff4356e089f2d98c0a6
@@ -1811,6 +1858,9 @@ checksums:
Uworke/message: 213d63da450f35dabb3ab0e35e29feed
Uworke/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
Uworke/translation: 213d63da450f35dabb3ab0e35e29feed
nG7mus/message: 03326832c645f4ddb873a6e7eab8a639
nG7mus/origin/0/0: 278a40eb45b1219f424703d0abc1d273
nG7mus/translation: 03326832c645f4ddb873a6e7eab8a639
IrZaAn/message: 1fcebe9ddb46f722a57f195efddc695d
IrZaAn/origin/0/0: 0627e0040ca4939c9a57349e98c51797
IrZaAn/translation: 1fcebe9ddb46f722a57f195efddc695d
@@ -1823,6 +1873,9 @@ checksums:
vwGkYB/message: 4c30501d085eaccea47af34212bb26a7
vwGkYB/origin/0/0: 0627e0040ca4939c9a57349e98c51797
vwGkYB/translation: 4c30501d085eaccea47af34212bb26a7
Xlkxmi/message: 3251e7239a9b349a86d5785a113cd6b9
Xlkxmi/origin/0/0: 0627e0040ca4939c9a57349e98c51797
Xlkxmi/translation: 3251e7239a9b349a86d5785a113cd6b9
lpIMne/message: 37ca1f4e0afc9a0b8e9617f767103c92
lpIMne/origin/0/0: 0627e0040ca4939c9a57349e98c51797
lpIMne/translation: 37ca1f4e0afc9a0b8e9617f767103c92
@@ -1867,6 +1920,9 @@ checksums:
Oi%2BJ%2BN/message: 313804fe615d51e0f0cbd599161d1ade
Oi%2BJ%2BN/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
GdgCoi/message: c6a08c771f97d86b02cf10d8f5f2d788
GdgCoi/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
GdgCoi/translation: c6a08c771f97d86b02cf10d8f5f2d788
Iqh0Uv/message: 0cc9dcd6ac624383e9977f66d4b3b777
Iqh0Uv/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
Iqh0Uv/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
@@ -1937,19 +1993,18 @@ checksums:
tyHiOa/origin/34/0: afee530613110e248ffde6b56872ebed
tyHiOa/origin/35/0: afee530613110e248ffde6b56872ebed
tyHiOa/origin/36/0: 8628ddb6f4ded25c0f17e2cbda8207d3
tyHiOa/origin/37/0: 8628ddb6f4ded25c0f17e2cbda8207d3
tyHiOa/origin/38/0: 7493592314bc3b96ad96127a949ba1ba
tyHiOa/origin/37/0: 7493592314bc3b96ad96127a949ba1ba
tyHiOa/origin/38/0: 69d8634b177fdf937764b406fd477547
tyHiOa/origin/39/0: 69d8634b177fdf937764b406fd477547
tyHiOa/origin/40/0: 69d8634b177fdf937764b406fd477547
tyHiOa/origin/41/0: 03c00909b41be01231cd3e843c8ee44e
tyHiOa/origin/42/0: 5c60161e173f5eae9276d8e4c70b4ebb
tyHiOa/origin/43/0: 83911e3eacbad4583e2b1647a784d154
tyHiOa/origin/44/0: d465da67c0a870d198eb7f313569c137
tyHiOa/origin/45/0: 33bf266228f835e12c0bfb0e5efb3be9
tyHiOa/origin/46/0: ff48991e314b0b3e989a4dac59860fd5
tyHiOa/origin/47/0: 30791e26a172d47d3cb0693e82679246
tyHiOa/origin/48/0: dbad0c8d7863cb41f5495264f82e7081
tyHiOa/origin/49/0: 0dccc39d0debbf3e089cb6d292de762b
tyHiOa/origin/40/0: 03c00909b41be01231cd3e843c8ee44e
tyHiOa/origin/41/0: 5c60161e173f5eae9276d8e4c70b4ebb
tyHiOa/origin/42/0: 83911e3eacbad4583e2b1647a784d154
tyHiOa/origin/43/0: d465da67c0a870d198eb7f313569c137
tyHiOa/origin/44/0: 33bf266228f835e12c0bfb0e5efb3be9
tyHiOa/origin/45/0: ff48991e314b0b3e989a4dac59860fd5
tyHiOa/origin/46/0: 30791e26a172d47d3cb0693e82679246
tyHiOa/origin/47/0: dbad0c8d7863cb41f5495264f82e7081
tyHiOa/origin/48/0: 0dccc39d0debbf3e089cb6d292de762b
tyHiOa/translation: 21ffcf0b00e7cd7b64f7454a95762e1d
PZCqeW/message: 325dea6dd0348a27a6818db2c1340c98
PZCqeW/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
@@ -1960,8 +2015,10 @@ checksums:
fuwKpE/origin/1/0: fd0e66cef8d46e96bcf3143a12f9b6db
fuwKpE/origin/2/0: fc0c18a095a68277a0f90a115942683b
fuwKpE/origin/3/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/4/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/5/0: faa8a2576972b4eb31565d8ea6098f43
fuwKpE/origin/4/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/5/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/6/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/7/0: faa8a2576972b4eb31565d8ea6098f43
fuwKpE/translation: 346a18a54564881c888b95873d03df44
nYNMZZ/message: d5a8a1b38eaf02d02cd6ac2211b42a92
nYNMZZ/origin/0/0: 97bd206a05414e2a571020d64e166093
@@ -1992,7 +2049,8 @@ checksums:
3fPjUY/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
3fPjUY/translation: 682b3c9feab30112b4454cb5bb7974b1
oyRjD%2F/message: 76a09fd843b496610f8cf1dced417b62
oyRjD%2F/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
oyRjD%2F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
oyRjD%2F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
oyRjD%2F/translation: 76a09fd843b496610f8cf1dced417b62
Qtzfdk/message: a898be9e4610706b9b8146a209e8ba16
Qtzfdk/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
@@ -2136,6 +2194,13 @@ checksums:
SkCNhl/message: 137017b2b3e885c4894ca8c899af5bc2
SkCNhl/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
SkCNhl/translation: 137017b2b3e885c4894ca8c899af5bc2
66Jmqh/message: 5f56ef5447d5d3ae81d26936ca8e6758
66Jmqh/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
66Jmqh/translation: 5f56ef5447d5d3ae81d26936ca8e6758
MpFIca/message: 6e3f470ef5468c5195ae09f518b4dea4
MpFIca/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
MpFIca/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
MpFIca/translation: 6e3f470ef5468c5195ae09f518b4dea4
e1v%2BJ3/message: 9aeca4f286e14966ef1c32d2abc74584
e1v%2BJ3/origin/0/0: f64cd62b620f454998523af2a4330278
e1v%2BJ3/translation: 9aeca4f286e14966ef1c32d2abc74584
@@ -2179,11 +2244,20 @@ checksums:
v3YoMA/message: 3fff865dc00435f82896fc302ea45630
v3YoMA/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
v3YoMA/translation: 3fff865dc00435f82896fc302ea45630
GXGbXi/message: 8686cb61f9bd3cd7ed100cc3af819458
GXGbXi/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
GXGbXi/translation: 8686cb61f9bd3cd7ed100cc3af819458
8AbRER/message: 3cb81c4829857556f5d117c9a23f7bcc
8AbRER/origin/0/0: 81b173297e9c022d9ebde5f7018c95f9
8AbRER/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
8AbRER/origin/2/0: 2201bf679c663a732d7f1b3c3ce3897f
8AbRER/translation: 3cb81c4829857556f5d117c9a23f7bcc
2gHjVM/message: 873c4eb14a7e32e7fd7793047b6028a1
2gHjVM/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
2gHjVM/origin/1/0: 627b72cd19a7154ae1c28cbd7ca0c730
2gHjVM/origin/2/0: 0627e0040ca4939c9a57349e98c51797
2gHjVM/origin/3/0: 0627e0040ca4939c9a57349e98c51797
2gHjVM/translation: 873c4eb14a7e32e7fd7793047b6028a1
Eit43O/message: b5d9a1edd988f455041aee9fa7fb3d63
Eit43O/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
Eit43O/translation: b5d9a1edd988f455041aee9fa7fb3d63
@@ -2224,6 +2298,13 @@ checksums:
n1ekoW/message: ec7b8f314fe9bc6591006707484ede61
n1ekoW/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
n1ekoW/translation: ec7b8f314fe9bc6591006707484ede61
jbGGzf/message: 7f18a6418cc7eb05158cff67247bc966
jbGGzf/placeholders/partnerName/0: f182b91b0442585d06f8bf9545912628
jbGGzf/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
jbGGzf/translation: 7f18a6418cc7eb05158cff67247bc966
4dGN6E/message: dd086c229fd7cc0247d4c1b5c6f096a8
4dGN6E/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
4dGN6E/translation: dd086c229fd7cc0247d4c1b5c6f096a8
fcWrnU/message: 6d842546a84e919c519bf8cbffdf25a0
fcWrnU/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
fcWrnU/origin/1/0: 69d8634b177fdf937764b406fd477547
@@ -2266,6 +2347,12 @@ checksums:
6sKjjx/message: 50861c0881fe87a77412417e5ffb3fbb
6sKjjx/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
6sKjjx/translation: 50861c0881fe87a77412417e5ffb3fbb
J9%2BzIR/message: d719cefeec51f2a6dd01064715a1fccc
J9%2BzIR/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
J9%2BzIR/translation: d719cefeec51f2a6dd01064715a1fccc
r%2BdxEH/message: c3d9e1523d1401870fbf56fa8bce8f8b
r%2BdxEH/origin/0/0: 278a40eb45b1219f424703d0abc1d273
r%2BdxEH/translation: c3d9e1523d1401870fbf56fa8bce8f8b
vnS6Rf/message: 003f7f93d44b0f5c2e40bf24e7af3bd7
vnS6Rf/origin/0/0: c0b59968b2b234167c488a05cc38710b
vnS6Rf/translation: 003f7f93d44b0f5c2e40bf24e7af3bd7
@@ -2273,9 +2360,13 @@ checksums:
R6pB8R/origin/0/0: 09a6a32f9a6b415e81b4d2f8181a0e6b
R6pB8R/translation: e5cb0428f1ac6485688f120d78742875
veIDCY/message: 4be2811a8f3455618032ccb59e40658b
veIDCY/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
veIDCY/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
veIDCY/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
veIDCY/translation: 4be2811a8f3455618032ccb59e40658b
4%2BJaWo/message: e346e4ed7d138dcc873db187922369da
4%2BJaWo/origin/0/0: 94d3e20c29bb517dd394711d7524e478
4%2BJaWo/origin/1/0: 94d3e20c29bb517dd394711d7524e478
4%2BJaWo/origin/2/0: 94d3e20c29bb517dd394711d7524e478
4%2BJaWo/translation: e346e4ed7d138dcc873db187922369da
Y8yzGg/message: 396a94648584067cc5795f7b32d3d663
Y8yzGg/origin/0/0: 82a5cf8d69f098641eb357bf102cd504
Y8yzGg/translation: 396a94648584067cc5795f7b32d3d663
@@ -2310,7 +2401,8 @@ checksums:
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
bff61F/message: 3f0ae9c914f16ceb9a0d7db868f4aa20
bff61F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
bff61F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
bff61F/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
bff61F/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
bff61F/translation: 3f0ae9c914f16ceb9a0d7db868f4aa20
CAL6E9/message: b63448c05270497973ac4407047dae02
CAL6E9/origin/0/0: c0b59968b2b234167c488a05cc38710b
@@ -2343,6 +2435,14 @@ checksums:
NnH3pK/message: 82a46f197779af4dde92e7e5f9336ccf
NnH3pK/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
NnH3pK/translation: 82a46f197779af4dde92e7e5f9336ccf
InJcdo/message: 1b2364c95fd318d99cc502745beac94a
InJcdo/origin/0/0: f64cd62b620f454998523af2a4330278
InJcdo/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
InJcdo/translation: 1b2364c95fd318d99cc502745beac94a
zDNL2V/message: 6e053a87e63372e903c16a06de25c9a3
zDNL2V/origin/0/0: f64cd62b620f454998523af2a4330278
zDNL2V/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
zDNL2V/translation: 6e053a87e63372e903c16a06de25c9a3
itgYbC/message: efe14181bc517316e3ab3258d72d2ff9
itgYbC/origin/0/0: f64cd62b620f454998523af2a4330278
itgYbC/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
@@ -2353,6 +2453,9 @@ checksums:
nhMhRr/message: 07edd8c50685a52c0969d711df26d768
nhMhRr/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
nhMhRr/translation: 07edd8c50685a52c0969d711df26d768
ssK7k%2B/message: 4321c1f033c350419ad198aaafe3b6fd
ssK7k%2B/origin/0/0: 278a40eb45b1219f424703d0abc1d273
ssK7k%2B/translation: 4321c1f033c350419ad198aaafe3b6fd
NcFrgC/message: bd15a7b3d8b4bce18cd42682ea1ec164
NcFrgC/origin/0/0: 0ad2af981579a62b084b3d41462c5546
NcFrgC/translation: bd15a7b3d8b4bce18cd42682ea1ec164
@@ -2428,6 +2531,9 @@ checksums:
mwBdZ2/message: b49224632bd6c3b7f5e462912aeb1081
mwBdZ2/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
mwBdZ2/translation: b49224632bd6c3b7f5e462912aeb1081
PV7XRK/message: 408b8862ad5ec9027eeec59b129ad18f
PV7XRK/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
PV7XRK/translation: 408b8862ad5ec9027eeec59b129ad18f
duf3Nr/message: b455329e2a71da677acab91d3a00bad6
duf3Nr/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
duf3Nr/translation: b455329e2a71da677acab91d3a00bad6
@@ -2437,6 +2543,9 @@ checksums:
kp6L3T/message: b7eadb89c615874f416d9658d0428c4c
kp6L3T/origin/0/0: 30791e26a172d47d3cb0693e82679246
kp6L3T/translation: b7eadb89c615874f416d9658d0428c4c
pEb95p/message: 1d4c2a0ec7e4c707a4470fa4262b495d
pEb95p/origin/0/0: 1b05beba6bdd908015fa8e798717f024
pEb95p/translation: 1d4c2a0ec7e4c707a4470fa4262b495d
MHrjPM/message: 344e64395eaff6822a57d18623853e1a
MHrjPM/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
MHrjPM/translation: 344e64395eaff6822a57d18623853e1a
@@ -2484,6 +2593,9 @@ checksums:
LeM5RY/message: dfeac280858332082ad34d2623a59863
LeM5RY/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
LeM5RY/translation: dfeac280858332082ad34d2623a59863
5MPY04/message: 661f2e21a85fbe1281b141a8041c8778
5MPY04/origin/0/0: 1b05beba6bdd908015fa8e798717f024
5MPY04/translation: 661f2e21a85fbe1281b141a8041c8778
W1ewR0/message: ddcac5d14d48358b043a9487b42eec16
W1ewR0/origin/0/0: fc0c18a095a68277a0f90a115942683b
W1ewR0/origin/1/0: 1b05beba6bdd908015fa8e798717f024
@@ -2503,6 +2615,9 @@ checksums:
YcyP2z/origin/0/0: 3161dd30737e7d849d4ccc30a28bada9
YcyP2z/origin/1/0: 3161dd30737e7d849d4ccc30a28bada9
YcyP2z/translation: 7495f1434c9188849bffd015811d5337
c1vOQR/message: 640ab5162eed616a7235d68376a016ed
c1vOQR/origin/0/0: f64cd62b620f454998523af2a4330278
c1vOQR/translation: 640ab5162eed616a7235d68376a016ed
MxQXhL/message: c1c949477e121d73ed6683eddf9cd874
MxQXhL/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
MxQXhL/origin/1/0: 69d8634b177fdf937764b406fd477547
@@ -2522,8 +2637,12 @@ checksums:
2QGEbi/message: 550198b2c87f06726a843c79c1026ed9
2QGEbi/origin/0/0: 4b4f55c033add8db69178563832227c6
2QGEbi/translation: 550198b2c87f06726a843c79c1026ed9
23nvb2/message: b9dedce59b5594ee2a88bf5d55d6dfac
23nvb2/origin/0/0: a1fde3fcce608870830f168f027727ff
23nvb2/translation: b9dedce59b5594ee2a88bf5d55d6dfac
Oo4E6p/message: af985cf72c47df76e2f2139a427d9948
Oo4E6p/origin/0/0: 97bd206a05414e2a571020d64e166093
Oo4E6p/origin/1/0: 1b05beba6bdd908015fa8e798717f024
Oo4E6p/translation: af985cf72c47df76e2f2139a427d9948
K7k9u3/message: 1cf9d227042f6a65adba38cb7296830d
K7k9u3/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
@@ -2543,6 +2662,10 @@ checksums:
Q60WUZ/message: 4284c33aed2f2c5e004ed0a7d7d9bf87
Q60WUZ/origin/0/0: 69d8634b177fdf937764b406fd477547
Q60WUZ/translation: 4284c33aed2f2c5e004ed0a7d7d9bf87
KNK33q/message: a606d59661b59b5e2e9ab58e8cbc143b
KNK33q/origin/0/0: f64cd62b620f454998523af2a4330278
KNK33q/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
KNK33q/translation: a606d59661b59b5e2e9ab58e8cbc143b
T7Mywq/message: 62899897d7bffcb1df3af77d460f0881
T7Mywq/origin/0/0: 0ad2af981579a62b084b3d41462c5546
T7Mywq/translation: 62899897d7bffcb1df3af77d460f0881
@@ -2585,6 +2708,9 @@ checksums:
TFv5tM/message: 4f2240aeff6f7275feb33ca3f608e48c
TFv5tM/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
TFv5tM/translation: 4f2240aeff6f7275feb33ca3f608e48c
V1o9Jo/message: 7b212f1c2fdc370974f859b08349b0c6
V1o9Jo/origin/0/0: f64cd62b620f454998523af2a4330278
V1o9Jo/translation: 7b212f1c2fdc370974f859b08349b0c6
It4%2FFS/message: ae1527c2765592f13980acb66997e570
It4%2FFS/origin/0/0: 0ad2af981579a62b084b3d41462c5546
It4%2FFS/translation: ae1527c2765592f13980acb66997e570
@@ -2628,6 +2754,9 @@ checksums:
7Ufoyg/message: 5c418701b27b9acbc5dd199719007362
7Ufoyg/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
7Ufoyg/translation: 5c418701b27b9acbc5dd199719007362
uXKtPH/message: 8a95d730b360db6041001450aa751ab4
uXKtPH/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
uXKtPH/translation: 8a95d730b360db6041001450aa751ab4
mx8%2B1u/message: f2949ca2dc18b0063af92e60779abb65
mx8%2B1u/origin/0/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/1/0: c0b59968b2b234167c488a05cc38710b
@@ -2637,6 +2766,9 @@ checksums:
8AwlaR/message: acb1fdc14ed875815ae9f8a5254bc634
8AwlaR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
8AwlaR/translation: acb1fdc14ed875815ae9f8a5254bc634
i5yNAO/message: 478092018a27a4e588cb5c70968ef11a
i5yNAO/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
i5yNAO/translation: 478092018a27a4e588cb5c70968ef11a
Ws%2B3X%2B/message: 905b10eeb09fdc5ac7fc757e935ee757
Ws%2B3X%2B/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
Ws%2B3X%2B/translation: 905b10eeb09fdc5ac7fc757e935ee757
@@ -2670,10 +2802,12 @@ checksums:
zERArS/placeholders/0/0: 06f23520ebabfffed4999b5bcf61c9cd
zERArS/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
zERArS/translation: aca196d729401d8adc5c8ca9d558840e
kwkhPe/message: 63c3b52882e0d779859307d672c178c2
kwkhPe/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
kwkhPe/origin/1/0: 15ea2dfd5eae4e05b226b1320103476e
kwkhPe/translation: 63c3b52882e0d779859307d672c178c2
IelE1h/message: 972773025e763ddf53273df6d37a729a
IelE1h/origin/0/0: 94d3e20c29bb517dd394711d7524e478
IelE1h/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
IelE1h/origin/2/0: 5f6c4a0c9be5098099167871ae1564af
IelE1h/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
IelE1h/translation: 972773025e763ddf53273df6d37a729a
cWtfn1/message: 9a91ea70f5eb55aac96640bf0fd7feff
cWtfn1/origin/0/0: 69d8634b177fdf937764b406fd477547
@@ -2746,12 +2880,22 @@ checksums:
"%2BEkBs0/message": 69d1d2575f2e73b08ac622bde0a45b4d
"%2BEkBs0/origin/0/0": d1e1d7ccce60e05570bedfbb93fc9416
"%2BEkBs0/translation": 69d1d2575f2e73b08ac622bde0a45b4d
7sdSkl/message: 6132de0281f288a30690bb2218089951
7sdSkl/placeholders/magicLinkRecipient/0: 63ab7807b81db57469a3b7fa40e6826e
7sdSkl/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
7sdSkl/translation: 6132de0281f288a30690bb2218089951
9y1RcT/message: 2dfa121e0ff1914e08068308a81973c0
9y1RcT/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
9y1RcT/translation: 2dfa121e0ff1914e08068308a81973c0
an6ayw/message: 2d39f6440ad379e417193dbfaa265b5f
an6ayw/origin/0/0: f64cd62b620f454998523af2a4330278
an6ayw/translation: 2d39f6440ad379e417193dbfaa265b5f
GdWB%2BV/message: 6a15a9b926ecd2c91454e9da19c6049e
GdWB%2BV/origin/0/0: f64cd62b620f454998523af2a4330278
GdWB%2BV/translation: 6a15a9b926ecd2c91454e9da19c6049e
LnaC5y/message: 229d2a9159e17f243cf317b9bd7d471c
LnaC5y/origin/0/0: a1fde3fcce608870830f168f027727ff
LnaC5y/translation: 229d2a9159e17f243cf317b9bd7d471c
2X4ecw/message: fcefd247ec76a372002d2cffac3c5b0f
2X4ecw/origin/0/0: a1fde3fcce608870830f168f027727ff
2X4ecw/translation: fcefd247ec76a372002d2cffac3c5b0f
@@ -2765,6 +2909,9 @@ checksums:
8iP9oQ/origin/0/0: f64cd62b620f454998523af2a4330278
8iP9oQ/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
8iP9oQ/translation: 5c07446c838d07572c6b40621507e951
kxZ7LS/message: 41ebcdff41ce85329915754faf7140c0
kxZ7LS/origin/0/0: f64cd62b620f454998523af2a4330278
kxZ7LS/translation: 41ebcdff41ce85329915754faf7140c0
3d54Wj/message: d67dc42810694fd1344a9a9545aca1e7
3d54Wj/origin/0/0: f64cd62b620f454998523af2a4330278
3d54Wj/translation: d67dc42810694fd1344a9a9545aca1e7
@@ -2908,12 +3055,22 @@ checksums:
MdN5zD/message: e2e816f2b7a13b1056d79e7f25088664
MdN5zD/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
MdN5zD/translation: e2e816f2b7a13b1056d79e7f25088664
ZhQoZn/message: b05a1191bf048d76e7d6c1c3e76ce1fd
ZhQoZn/origin/0/0: 0627e0040ca4939c9a57349e98c51797
ZhQoZn/translation: b05a1191bf048d76e7d6c1c3e76ce1fd
2SePRT/message: 257b840726f972f384243a72767f880f
2SePRT/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
2SePRT/translation: 257b840726f972f384243a72767f880f
uOqaMC/message: 24fc6cdc8740f37a83df85f582f03293
uOqaMC/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
uOqaMC/translation: 24fc6cdc8740f37a83df85f582f03293
QriiAI/message: 37a6b38e70fdfef1e2208ede67aeab98
QriiAI/placeholders/0/0: 4ab04dd216aebb6485be0c4b94d5ca6b
QriiAI/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
QriiAI/translation: 37a6b38e70fdfef1e2208ede67aeab98
8Zjc7q/message: 65609a209e74f230563a7c8d7258a83c
8Zjc7q/origin/0/0: 278a40eb45b1219f424703d0abc1d273
8Zjc7q/translation: 65609a209e74f230563a7c8d7258a83c
GoDLB9/message: 8c8d944e07388c5877effdb2c2803dcf
GoDLB9/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
GoDLB9/translation: 8c8d944e07388c5877effdb2c2803dcf
@@ -2938,9 +3095,18 @@ checksums:
PELbXB/message: 6984aed03bbcd94041c5dd20f62e56ed
PELbXB/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
PELbXB/translation: 6984aed03bbcd94041c5dd20f62e56ed
ygpeZ8/message: 59444b15ae58f987931a4746f4c77a0b
ygpeZ8/origin/0/0: 278a40eb45b1219f424703d0abc1d273
ygpeZ8/translation: 59444b15ae58f987931a4746f4c77a0b
"%2BA6Wuq/message": 36d33d3622921dbb1475da2acb98f754
"%2BA6Wuq/origin/0/0": 278a40eb45b1219f424703d0abc1d273
"%2BA6Wuq/translation": 36d33d3622921dbb1475da2acb98f754
Ozt2vv/message: 8f730f68d52b7efa727aa0784282ebcb
Ozt2vv/origin/0/0: 0627e0040ca4939c9a57349e98c51797
Ozt2vv/translation: 8f730f68d52b7efa727aa0784282ebcb
EuK5ez/message: d262a1ba9ebeea549d982f9cf85a6c18
EuK5ez/origin/0/0: 0627e0040ca4939c9a57349e98c51797
EuK5ez/translation: d262a1ba9ebeea549d982f9cf85a6c18
"%2FtOdd2/message": 099e1780348cd7bf617721344371a430
"%2FtOdd2/origin/0/0": 03c00909b41be01231cd3e843c8ee44e
"%2FtOdd2/translation": 099e1780348cd7bf617721344371a430

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";
@@ -159,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,6 +1,7 @@
import { useTheme } from "next-themes";
import { useRouter } from "next/navigation";
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 {
TbLayoutSidebarLeftCollapse,
@@ -13,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 {
@@ -43,9 +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(
@@ -99,16 +106,67 @@ export default function Dashboard({
}
});
useEffect(() => {
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()}`);
}
}, [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)}`);
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";
return (
@@ -203,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

@@ -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;
@@ -457,6 +457,17 @@ export default function Editor({
}) {
const containerRef = useRef<HTMLDivElement>(null);
// useEditor is created once (empty deps below), so keep the latest callbacks
// in refs to avoid the editor capturing stale closures on re-render.
const onChangeRef = useRef(onChange);
const onBlurRef = useRef(onBlur);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
onBlurRef.current = onBlur;
}, [onBlur]);
const editor = useEditor(
{
extensions: [
@@ -474,7 +485,7 @@ export default function Editor({
autolink: true,
linkOnPaste: true,
}),
Markdown,
Markdown.configure({ transformPastedText: true }),
Placeholder.configure({
placeholder: readOnly
? ""
@@ -553,7 +564,7 @@ export default function Editor({
...(enableYouTubeEmbed ? [YouTubeNode] : []),
],
content,
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getHTML()),
onBlur: ({ event }) => {
if (
document
@@ -563,7 +574,7 @@ export default function Editor({
return;
// Only trigger onBlur if the click was outside both the editor and menu
if (!containerRef.current?.contains(event.relatedTarget as Node)) {
onBlur?.();
onBlurRef.current?.();
}
},
editorProps: {
@@ -587,6 +598,16 @@ export default function Editor({
}
}, [content, editor]);
// useEditor captures `readOnly` once at creation time (empty deps above), so
// explicitly sync `editable` when the prop changes. Without this the editor
// gets stuck read-only when `readOnly` flips from true to false after mount
// (e.g. card permissions resolving slower than the card data on first load).
useEffect(() => {
if (!editor) return;
if (editor.isEditable === !readOnly) return;
editor.setEditable(!readOnly);
}, [editor, readOnly]);
return (
<div ref={containerRef}>
<style jsx global>{`

View File

@@ -96,6 +96,7 @@ export function NewWorkspaceForm() {
description: values.description,
slug: values.slug,
plan: values.plan,
cardPrefix: values.cardPrefix,
role: "admin",
weekStartDay: 1,
});

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

@@ -1,6 +1,6 @@
import { useRouter } from "next/navigation";
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";
@@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import CommandPallette from "./CommandPallette";
import { Tooltip } from "./Tooltip";
@@ -20,6 +21,8 @@ export default function WorkspaceMenu({
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace();
const { openModal } = useModal();
const { data: hasPartnerSlot } =
api.workspace.hasAvailablePartnerSlot.useQuery();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
@@ -150,11 +153,19 @@ export default function WorkspaceMenu({
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
<Menu.Item>
<button
onClick={() =>
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`)
: openModal("NEW_WORKSPACE")
}
onClick={() => {
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
openModal("NEW_WORKSPACE");
} else if (hasPartnerSlot) {
router.push(
`/onboarding/workspace?partner=1&returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
} else {
router.push(
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
}
}}
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;

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,20 @@
export type WorkspacePlan = "free" | "team" | "pro" | "enterprise";
export interface TierConfig {
plan: WorkspacePlan;
seats: number | null;
unlimitedSeats: boolean;
workspaceSlots: number;
}
const TIER_MAP: Record<number, TierConfig> = {
1: { plan: "team", seats: 5, unlimitedSeats: false, workspaceSlots: 1 },
2: { plan: "pro", seats: 15, unlimitedSeats: false, workspaceSlots: 2 },
3: { plan: "pro", seats: null, unlimitedSeats: true, workspaceSlots: 4 },
};
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,144 @@
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 { 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 status = license.status === "active" ? "active" : "inactive";
// Ensure subscription slots exist — webhook may have already created them
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license.license_key,
);
if (existing.length === 0) {
await subscriptionRepo.createPartnerLicenseSlots(
db,
license.license_key,
{
plan: cfg.plan,
status,
partnerTier: license.tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
cfg.workspaceSlots,
);
}
if (!user) {
return res.redirect(
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
);
}
return res.redirect(
`/api/partner/link?license_key=${encodeURIComponent(license.license_key)}`,
);
}),
);

View File

@@ -0,0 +1,98 @@
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 allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license_key,
);
if (!allSlots.length) {
return res.redirect("/boards?partner_error=invalid_license");
}
const activeSlots = allSlots.filter((s) =>
["active", "trialing"].includes(s.status),
);
if (!activeSlots.length) {
return res.redirect("/boards?partner_error=license_inactive");
}
const unlinkedSlot = activeSlots.find((s) => !s.referenceId);
if (!unlinkedSlot) {
return res.redirect("/boards?partner_activated=1");
}
const linkedIds = new Set(
activeSlots.filter((s) => s.referenceId).map((s) => s.referenceId!),
);
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
const availableWorkspace = memberships
.map((m) => m.workspace)
.find((w) => w && !w.deletedAt && !linkedIds.has(w.publicId));
if (!availableWorkspace) {
return res.redirect(
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
);
}
await subscriptionRepo.updateById(db, unlinkedSlot.id, {
referenceId: availableWorkspace.publicId,
});
await workspaceRepo.update(db, availableWorkspace.publicId, {
plan: unlinkedSlot.plan as "free" | "team" | "pro" | "enterprise",
});
const remainingUnlinked = activeSlots.filter(
(s) => !s.referenceId && s.id !== unlinkedSlot.id,
);
if (remainingUnlinked.length > 0) {
const updatedLinkedIds = new Set([
...linkedIds,
availableWorkspace.publicId,
]);
const hasMoreAvailableWorkspace = memberships
.map((m) => m.workspace)
.some((w) => w && !w.deletedAt && !updatedLinkedIds.has(w.publicId));
if (hasMoreAvailableWorkspace) {
return res.redirect(
`/api/partner/link?license_key=${encodeURIComponent(license_key)}`,
);
}
}
return res.redirect("/boards?partner_activated=1");
}),
);

View File

@@ -0,0 +1,418 @@
import { createHmac, timingSafeEqual } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import type { Readable } from "node:stream";
import type { dbClient } from "@kan/db/client";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { cancelWorkspaceAccess } from "@kan/api/utils/workspace";
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 type { TierConfig } from "./_utils";
import { tierConfig } from "./_utils";
const log = createLogger("api");
async function createAndLinkSlots(
db: dbClient,
licenseKey: string,
cfg: TierConfig,
tier: number,
alreadyLinkedReferenceIds: Set<string>,
count: number,
) {
const linkedId = [...alreadyLinkedReferenceIds][0];
let autoLinked = 0;
if (linkedId) {
const linkedWorkspace = await workspaceRepo.getByPublicId(db, linkedId);
if (linkedWorkspace?.createdBy) {
const owned = await workspaceRepo.getAllOwnedByUserId(
db,
linkedWorkspace.createdBy,
);
const candidates = owned.filter(
(w) => !alreadyLinkedReferenceIds.has(w.publicId),
);
if (candidates.length > 0) {
const existingSubs =
await subscriptionRepo.getAllActivePartnerSubsByWorkspaceIds(
db,
candidates.map((w) => w.publicId),
);
const alreadySubscribed = new Set(
existingSubs
.map((s) => s.referenceId)
.filter((id): id is string => !!id),
);
const available = candidates
.filter((w) => !alreadySubscribed.has(w.publicId))
.slice(0, count);
if (available.length > 0) {
const newSlots = await subscriptionRepo.createPartnerLicenseSlots(
db,
licenseKey,
{
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
available.length,
);
await Promise.all(
available.map((workspace, i) => {
const slot = newSlots[i];
if (!slot) return;
return Promise.all([
subscriptionRepo.updateById(db, slot.id, {
referenceId: workspace.publicId,
}),
workspaceRepo.update(db, workspace.publicId, {
plan: cfg.plan,
}),
]);
}),
);
autoLinked = available.length;
}
}
}
}
const remaining = count - autoLinked;
if (remaining > 0) {
await subscriptionRepo.createPartnerLicenseSlots(
db,
licenseKey,
{
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
remaining,
);
}
}
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;
}
}
function hasReferenceId<T extends { referenceId: string | null | undefined }>(
s: T,
): s is T & { referenceId: string } {
return !!s.referenceId;
}
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":
case "activate": {
const cfg = tierConfig(tier);
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license_key,
);
const status = event === "activate" ? "active" : license_status;
if (existing.length === 0) {
await subscriptionRepo.createPartnerLicenseSlots(
db,
license_key,
{
plan: cfg.plan,
status,
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
cfg.workspaceSlots,
);
} else {
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status,
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
}
break;
}
case "deactivate": {
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license_key,
);
await Promise.all(
allSlots.filter(hasReferenceId).map(async (slot) => {
const siblingSubs = await subscriptionRepo.getByReferenceId(
db,
slot.referenceId,
);
const hasOtherActiveSub = getActiveSubscriptions(siblingSubs).some(
(s) => s.id !== slot.id,
);
if (!hasOtherActiveSub) {
await cancelWorkspaceAccess(db, slot.referenceId);
}
}),
);
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
plan: "free",
status: "canceled",
unlimitedSeats: false,
seats: null,
});
break;
}
case "upgrade":
case "downgrade": {
const lookupKey = prev_license_key ?? license_key;
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
lookupKey,
);
if (existing.length === 0) break;
const cfg = tierConfig(tier);
const newCount = cfg.workspaceSlots;
// Prefer keeping linked slots; among linked, keep in insertion order (LIFO removal)
const preferKeep = [
...existing.filter((s) => s.referenceId),
...existing.filter((s) => !s.referenceId),
];
const slotsToKeep = preferKeep.slice(0, newCount);
const slotsToRemove = preferKeep.slice(newCount);
await Promise.all([
...slotsToKeep.map((slot) =>
subscriptionRepo.updateById(db, slot.id, {
plan: cfg.plan,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
partnerTier: tier,
status: "active",
...(prev_license_key ? { partnerLicenseKey: license_key } : {}),
}),
),
...slotsToKeep
.filter(hasReferenceId)
.map((s) =>
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
),
]);
if (newCount > existing.length) {
const linkedIds = new Set(
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
);
await createAndLinkSlots(
db,
license_key,
cfg,
tier,
linkedIds,
newCount - existing.length,
);
}
if (slotsToRemove.length > 0) {
await Promise.all([
...slotsToRemove
.filter(hasReferenceId)
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
...slotsToRemove.map((s) =>
subscriptionRepo.updateById(db, s.id, {
plan: "free",
status: "inactive",
unlimitedSeats: false,
seats: null,
}),
),
]);
}
break;
}
case "migrate": {
if (!prev_license_key) break;
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
prev_license_key,
);
if (existing.length === 0) break;
const cfg = tierConfig(tier);
const newCount = cfg.workspaceSlots;
const preferKeep = [
...existing.filter((s) => s.referenceId),
...existing.filter((s) => !s.referenceId),
];
const slotsToKeep = preferKeep.slice(0, newCount);
const slotsToRemove = preferKeep.slice(newCount);
await Promise.all([
...slotsToKeep.map((slot) =>
subscriptionRepo.updateById(db, slot.id, {
partnerLicenseKey: license_key,
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
}),
),
...slotsToKeep
.filter(hasReferenceId)
.map((s) =>
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
),
]);
if (newCount > existing.length) {
const linkedIds = new Set(
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
);
await createAndLinkSlots(
db,
license_key,
cfg,
tier,
linkedIds,
newCount - existing.length,
);
}
if (slotsToRemove.length > 0) {
await Promise.all([
...slotsToRemove
.filter(hasReferenceId)
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
...slotsToRemove.map((s) =>
subscriptionRepo.updateById(db, s.id, {
plan: "free",
status: "inactive",
unlimitedSeats: false,
seats: null,
}),
),
]);
}
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

@@ -64,7 +64,10 @@ export default async function handler(
meta.userId &&
meta.userEmail
) {
const existing = await workspaceRepo.getByPublicId(db, meta.workspacePublicId);
const existing = await workspaceRepo.getByPublicId(
db,
meta.workspacePublicId,
);
if (!existing) {
const slug = meta.workspaceSlug ?? meta.workspacePublicId;
@@ -90,10 +93,10 @@ export default async function handler(
});
}
} else {
// Existing workspace upgrade — update plan (and slug for pro)
await workspaceRepo.update(db, meta.workspacePublicId, {
plan,
...(plan === "pro" && meta.workspaceSlug && { slug: meta.workspaceSlug }),
...(plan === "pro" &&
meta.workspaceSlug && { slug: meta.workspaceSlug }),
});
}

View File

@@ -67,8 +67,15 @@ export default withRateLimit(
return res.status(400).json({ error: "File too large" });
}
const originalFilenameHeader =
const rawFilenameHeader =
(req.headers["x-original-filename"] as string | undefined) ?? "file";
const originalFilenameHeader = (() => {
try {
return decodeURIComponent(rawFilenameHeader);
} catch {
return rawFilenameHeader;
}
})();
const sanitizedFilename = originalFilenameHeader
.replace(/[^a-zA-Z0-9._-]/g, "_")

View File

@@ -64,8 +64,15 @@ export default withRateLimit(
return res.status(400).json({ error: "File too large" });
}
const originalFilenameHeader =
const rawFilenameHeader =
(req.headers["x-original-filename"] as string | undefined) ?? "file";
const originalFilenameHeader = (() => {
try {
return decodeURIComponent(rawFilenameHeader);
} catch {
return rawFilenameHeader;
}
})();
const sanitizedFilename = originalFilenameHeader
.replace(/[^a-zA-Z0-9._-]/g, "_")

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

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

@@ -20,6 +20,7 @@ interface Workspace {
plan: "free" | "team" | "pro" | "enterprise" | undefined;
role: "admin" | "member" | "guest";
weekStartDay: 0 | 1 | 6;
cardPrefix: string;
}
const initialWorkspace: Workspace = {
@@ -30,6 +31,7 @@ const initialWorkspace: Workspace = {
plan: "free" as const,
role: "member",
weekStartDay: 1,
cardPrefix: "",
};
const initialAvailableWorkspaces: Workspace[] = [];
@@ -56,9 +58,12 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
const pollAttemptsRef = React.useRef(0);
const MAX_POLL_ATTEMPTS = 5;
const { data, isLoading } = api.workspace.all.useQuery(undefined, {
refetchInterval: pendingWorkspaceId ? 2000 : false,
});
const { data, isLoading, isFetching } = api.workspace.all.useQuery(
undefined,
{
refetchInterval: pendingWorkspaceId ? 2000 : false,
},
);
const utils = api.useUtils();
const switchWorkspace = (_workspace: Workspace) => {
@@ -74,7 +79,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
if (!data?.length) {
if (!isLoading) setHasLoaded(true);
if (!isLoading && !isFetching) setHasLoaded(true);
return;
}
@@ -90,6 +95,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
description: workspace.description,
plan: workspace.plan,
weekStartDay: workspace.weekStartDay,
cardPrefix: workspace.cardPrefix,
hasLoaded: true,
})) as Workspace[];
@@ -97,53 +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) {
pollAttemptsRef.current += 1;
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
setPendingWorkspaceId(null);
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 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);
}
setHasLoaded(true);
return;
}
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,
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
});
if (workspacePublicId) {
router.push(`/boards`);
localStorage.setItem("workspacePublicId", workspacePublicId);
}
} 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,
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
});
}
}, [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

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

@@ -26,7 +26,7 @@ export default function LoginPage() {
const { data } = authClient.useSession();
if (data?.user.id) router.push("/boards");
if (data?.user.id) router.push(redirect ?? "/boards");
return (
<>

View File

@@ -21,7 +21,7 @@ export default function SignUpPage() {
const { data } = authClient.useSession();
if (data?.user.id) router.push("/boards");
if (data?.user.id) router.push(redirect ?? "/boards");
const handleMagicLinkSent = (value: boolean, recipient: string) => {
setIsMagicLinkSent(value);

View File

@@ -88,7 +88,7 @@ export default function BoardDropdown({
const isArchiveActionPending = updateBoard.isPending;
const items = [
...(isTemplate && canCreateBoard
...(!isTemplate && canCreateBoard
? [
{
label: t`Make template`,

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

@@ -95,7 +95,7 @@ export default function List({
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] snap-start rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100 md:snap-align-none"
>
<div className="mb-2 flex justify-between">
<form
@@ -118,7 +118,7 @@ export default function List({
}
>
<button
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 disabled:opacity-60 disabled:cursor-not-allowed dark:hover:bg-dark-200"
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-dark-200"
onClick={() => openNewCardForm(list.publicId)}
disabled={!canCreateCard}
>

View File

@@ -146,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),
),

View File

@@ -2,9 +2,9 @@ import type { DropResult } from "react-beautiful-dnd";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useRouter } from "next/router";
import { env } from "next-runtime-env";
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";
@@ -634,7 +634,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
<div
ref={scrollRef}
onMouseDown={onMouseDown}
className={`scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300`}
className={`scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 snap-x snap-mandatory scroll-pl-[10px] overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300 md:snap-none`}
>
{isLoading ? (
<div className="ml-[2rem] flex">
@@ -681,11 +681,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
>
{(provided) => (
<div
className="flex"
className="flex w-max"
ref={provided.innerRef}
{...provided.droppableProps}
>
<div className="min-w-[2rem]" />
<div className="min-w-[10px] md:min-w-[2rem]" />
{boardData.lists.map((list, index) => (
<List
index={index}
@@ -757,6 +757,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
>
<Card
title={card.title}
ticketNumber={
card.cardNumber != null
? `${boardData.workspace.cardPrefix}-${card.cardNumber}`
: null
}
labels={card.labels}
members={card.members}
checklists={card.checklists ?? []}
@@ -777,7 +782,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
</Droppable>
</List>
))}
<div className="min-w-[0.75rem]" />
<div className="min-w-[calc(100vw-18rem)] md:min-w-[0.75rem]" />
{provided.placeholder}
</div>
)}

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,4 +1,5 @@
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useRef, useState } from "react";
import { HiOutlinePaperClip } from "react-icons/hi";
import { HiCheckBadge } from "react-icons/hi2";
@@ -7,7 +8,6 @@ import { twMerge } from "tailwind-merge";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { env } from "next-runtime-env";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
@@ -30,7 +30,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
method: "POST",
headers: {
"Content-Type": file.type,
"x-original-filename": file.name,
"x-original-filename": encodeURIComponent(file.name),
},
body: file,
},

View File

@@ -1,8 +1,10 @@
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
HiHashtag,
HiLink,
HiOutlineCheckCircle,
HiOutlineDocumentDuplicate,
HiOutlineTrash,
} from "react-icons/hi2";
@@ -12,24 +14,52 @@ import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
export default function CardDropdown({
cardPublicId,
isTemplate,
boardPublicId,
cardCreatedBy,
ticketNumber,
listPublicId,
cardIndex,
}: {
cardPublicId: string;
isTemplate?: boolean;
boardPublicId?: string;
cardCreatedBy?: string | null;
ticketNumber?: string | null;
listPublicId?: string;
cardIndex?: number;
}) {
const { openModal } = useModal();
const { showPopup } = usePopup();
const { canEditCard, canDeleteCard } = usePermissions();
const { data: session } = authClient.useSession();
const utils = api.useUtils();
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
const duplicateCard = api.card.duplicate.useMutation({
onSuccess: () => {
showPopup({
header: t`Card duplicated`,
icon: "success",
message: t`Card duplicated successfully.`,
});
},
onError: () => {
showPopup({
header: t`Unable to duplicate card`,
icon: "error",
message: t`Please try again.`,
});
},
onSettled: async () => {
await utils.board.byId.invalidate();
},
});
const handleCopyCardLink = async () => {
const path =
isTemplate && boardPublicId
@@ -53,12 +83,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
? [
{
@@ -68,6 +126,24 @@ export default function CardDropdown({
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
),
},
{
label: t`Duplicate card`,
action: () => {
if (!listPublicId || cardIndex === undefined) return;
duplicateCard.mutate({
cardPublicId,
listPublicId,
index: cardIndex + 1,
copyLabels: true,
copyMembers: true,
copyChecklists: true,
});
},
icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
),
disabled: duplicateCard.isPending || !listPublicId,
},
]
: []),
...(canDeleteCard || isCreator

View File

@@ -185,7 +185,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0]
: router.query.cardId;
const { data: card, isLoading, error } = api.card.byId.useQuery(
const {
data: card,
isLoading,
error,
} = api.card.byId.useQuery(
{ cardPublicId: cardId ?? "" },
{ enabled: !!cardId && cardId.length >= 12 },
);
@@ -340,6 +344,15 @@ 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
@@ -347,6 +360,14 @@ 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
}
listPublicId={card?.list.publicId}
cardIndex={card?.index}
/>
<Link
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}

View File

@@ -36,6 +36,17 @@ export default function InvitePage() {
return router.push(`/boards`);
}
if (
error.data?.code === "FORBIDDEN" &&
error.message === "SEAT_LIMIT_REACHED"
) {
setError(
t`This workspace has reached its member limit. The workspace owner will need to upgrade their plan.`,
);
setIsProcessing(false);
return;
}
setError(
error.message ||
t`Failed to accept invitation. Please try again later, or contact customer support.`,

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,15 @@ import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function InviteMemberForm({
numberOfMembers,
subscriptions,
unlimitedSeats,
userId,
memberCount,
seatLimit,
}: {
numberOfMembers: number;
subscriptions: Subscription[] | undefined;
unlimitedSeats: boolean;
userId: string | undefined;
memberCount: number;
seatLimit: number | null;
}) {
const utils = api.useUtils();
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
@@ -98,6 +97,15 @@ export function InviteMemberForm({
message: t`User is already a member of this workspace`,
icon: "error",
});
} else if (
error.data?.code === "FORBIDDEN" &&
error.message === "SEAT_LIMIT_REACHED"
) {
showPopup({
header: t`Seat limit reached`,
message: t`You've reached your ${seatLimit ?? 0}-seat limit. Please upgrade your plan to add more members.`,
icon: "error",
});
} else {
showPopup({
header: t`Error inviting member`,
@@ -153,6 +161,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 +186,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 +230,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 +256,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 +267,92 @@ 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>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
(isPartnerTier && seatLimit !== null ? (
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
<div className="flex items-center justify-between">
<span className="font-medium text-emerald-500 dark:text-emerald-400">
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan`}
{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 className="text-light-900 dark:text-dark-900">
{memberCount} / {seatLimit} {t`seats`}
</span>
<p className="mt-1">
{t`Inviting members requires a Team Plan. You'll be redirected to upgrade your workspace.`}
</p>
</div>
)}
</div>
)}
</div>
) : !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>
<p className="mt-1">
{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>
) : null)}
</div>
<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={
isShareInviteLinkEnabled
? t`Deactivate invite link`
: t`Create invite link`
}
isChecked={isShareInviteLinkEnabled}
onChange={handleInviteLinkToggle}
/>
{!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

@@ -11,7 +11,11 @@ import { twMerge } from "tailwind-merge";
import type { Subscription } from "@kan/shared/utils";
import { authClient } from "@kan/auth/client";
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
import {
getSeatLimit,
getSubscriptionByPlan,
hasUnlimitedSeats,
} from "@kan/shared/utils";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
@@ -27,8 +31,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 +80,23 @@ 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 seatLimit = getSeatLimit(subscriptions);
const memberCount =
data?.members.filter((m) => m.status === "active" || m.status === "invited")
.length ?? 0;
const totalSeats =
teamSubscription?.seats ??
proSubscription?.seats ??
(isPaidPlan ? null : 1);
const TableRow = ({
memberPublicId,
memberId,
@@ -144,8 +165,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 +201,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 +284,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,10 +408,10 @@ export default function MembersPage() {
isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
>
<InviteMemberForm
userId={session?.user.id}
numberOfMembers={data?.members.length ?? 1}
subscriptions={subscriptions}
unlimitedSeats={unlimitedSeats}
memberCount={memberCount}
seatLimit={seatLimit}
/>
</Modal>

View File

@@ -1,6 +1,6 @@
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
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";
@@ -48,6 +48,7 @@ export default function SelectPlanView() {
(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, {
@@ -96,18 +97,44 @@ export default function SelectPlanView() {
},
];
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(`/onboarding/select-plan?plan=${plan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
router.replace(buildSelectPlanUrl(plan, billing));
};
const handleSetBilling = (b: Billing) => {
setBilling(b);
router.replace(`/onboarding/select-plan?plan=${selected}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`);
router.replace(buildSelectPlanUrl(selected, b));
};
const handleContinue = () =>
router.push(`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
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);
@@ -209,7 +236,11 @@ export default function SelectPlanView() {
{t`Cancel`}
</Button>
)}
<Button onClick={handleContinue}>{t`Continue`}</Button>
<Button onClick={() => void handleContinue()}>
{workspacePublicId && selected !== "solo"
? t`Upgrade`
: t`Continue`}
</Button>
</div>
</div>

View File

@@ -39,7 +39,17 @@ export default function WorkspaceNameView() {
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 || searchParams.get("partner") === "1";
const { showPopup } = usePopup();
useEffect(() => {
if (licenseKeyParam) {
localStorage.setItem("partnerLicenseKey", licenseKeyParam);
}
}, [licenseKeyParam]);
const [isProToggle, setIsProToggle] = useState(plan === "pro");
const effectivePlan = isProToggle ? "pro" : plan;
@@ -94,7 +104,16 @@ export default function WorkspaceNameView() {
if (!workspace.publicId) return;
localStorage.setItem("workspacePublicId", workspace.publicId);
void utils.workspace.all.invalidate();
router.push("/boards");
void utils.workspace.hasAvailablePartnerSlot.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({
@@ -110,7 +129,7 @@ export default function WorkspaceNameView() {
const handleContinue = async () => {
if (!name.trim()) return;
if (effectivePlan === "solo") {
if (effectivePlan === "solo" || isLicenseFlow) {
createWorkspace.mutate({
name: name.trim(),
...(description.trim() && { description: description.trim() }),
@@ -193,50 +212,52 @@ export default function WorkspaceNameView() {
maxLength={64}
/>
<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" />
{!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
) : null
}
/>
</div>
}
/>
</div>
)}
{plan !== "pro" && (
{!isLicenseFlow && plan !== "pro" && (
<div className="pb-2">
<Toggle
isChecked={isProToggle}
@@ -265,16 +286,18 @@ export default function WorkspaceNameView() {
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
<div className="ml-auto flex gap-2">
<Button
variant="ghost"
onClick={() =>
router.replace(
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
)
}
>
{t`Back`}
</Button>
{!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={

View File

@@ -95,8 +95,28 @@ export function CardModal({
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full p-8">
<div className="mb-6">
<div className="flex w-full items-center justify-between">
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1">
<div className="flex w-full items-start justify-between gap-4">
<div className="flex-1">
{isLoading ? (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<>
{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="font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{data?.title}
</h1>
</>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-1">
<button
type="button"
onClick={handleCopyCardLink}
@@ -134,17 +154,6 @@ export function CardModal({
/>
</button>
</div>
{isLoading ? (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<>
<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>
</>
)}
</div>
{labels.length > 0 && (
<div className="mt-2">

View File

@@ -165,7 +165,7 @@ export default function PublicBoardView() {
<div
ref={scrollRef}
onMouseDown={onMouseDown}
className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300"
className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 snap-x snap-mandatory scroll-pl-[10px] overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300 md:snap-none"
>
{isLoading || !router.isReady ? (
<div className="ml-[2rem] flex">
@@ -188,11 +188,11 @@ export default function PublicBoardView() {
</div>
) : (
<div className="flex">
<div className="min-w-[2rem]" />
<div className="min-w-[10px] md:min-w-[2rem]" />
{data?.lists.map((list) => (
<div
key={list.publicId}
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] snap-start rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100 md:snap-align-none"
>
<div className="flex justify-between">
<span className="mb-4 block px-4 pt-1 text-sm font-medium text-neutral-900 dark:text-dark-1000">
@@ -234,7 +234,7 @@ export default function PublicBoardView() {
</div>
</div>
))}
<div className="min-w-[0.75rem]" />
<div className="min-w-[calc(100vw-18rem)] md:min-w-[0.75rem]" />
</div>
)}
</div>

View File

@@ -85,17 +85,19 @@ export default function AccountSettings() {
{isCredentialsEnabled && (
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Change Password`}
{data?.hasPassword ? t`Change Password` : t`Set Password`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`You are about to change your password.`}
{data?.hasPassword
? t`You are about to change your password.`
: t`Set a password to enable password-based login.`}
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("CHANGE_PASSWORD")}
>
{t`Change Password`}
{data?.hasPassword ? t`Change Password` : t`Set Password`}
</Button>
</div>
</div>
@@ -113,7 +115,7 @@ export default function AccountSettings() {
modalSize="sm"
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
>
<ChangePasswordFormConfirmation />
<ChangePasswordFormConfirmation hasPassword={data?.hasPassword ?? false} />
</Modal>
{/* Global modals */}

View File

@@ -1,4 +1,6 @@
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button";
@@ -7,24 +9,49 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export default function BillingSettings() {
const { modalContentType, isOpen } = useModal();
const router = useRouter();
const isCloud = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
const { workspace } = useWorkspace();
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
);
const subscription = workspaceData?.subscriptions.find((s) =>
["active", "trialing", "past_due"].includes(s.status),
);
const planLabel = (() => {
if (!subscription) return t`Free (1 member)`;
if (subscription.unlimitedSeats) {
const name =
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
return `${name} (${t`unlimited members`})`;
}
if (subscription.seats != null) {
const name =
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
return `${name} (${subscription.seats} ${subscription.seats === 1 ? t`member` : t`members`})`;
}
return (
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1)
);
})();
const handleOpenBillingPortal = async () => {
try {
const response = await fetch("/api/stripe/create_billing_session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
});
const { url } = (await response.json()) as { url: string };
if (url) {
window.location.href = url;
}
if (url) window.location.href = url;
} catch (error) {
console.error("Error creating billing session:", error);
}
@@ -34,6 +61,28 @@ export default function BillingSettings() {
<>
<PageHead title={t`Settings | Billing`} />
<div className="border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Plan`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{planLabel}
</p>
{!subscription && isCloud && (
<div className="mb-8">
<Button
onClick={() =>
router.push(
`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`,
)
}
>
{t`Choose plan`}
</Button>
</div>
)}
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Billing`}

View File

@@ -1,11 +1,6 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { HiBolt } from "react-icons/hi2";
import type { Subscription } from "@kan/shared/utils";
import { hasActiveSubscription } from "@kan/shared/utils";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
@@ -17,43 +12,21 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
export default function WorkspaceSettings() {
const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace();
const { canEditWorkspace } = usePermissions();
const router = useRouter();
const { data } = api.user.getUser.useQuery();
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
);
const subscriptions = workspaceData?.subscriptions as
| Subscription[]
| undefined;
// Open upgrade modal if upgrade=pro is in URL params
useEffect(() => {
if (
router.query.upgrade === "pro" &&
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasActiveSubscription(subscriptions, "pro") &&
!hasOpenedUpgradeModal
) {
openModal("UPGRADE_TO_PRO");
setHasOpenedUpgradeModal(true);
}
}, [router.query.upgrade, subscriptions, openModal, hasOpenedUpgradeModal]);
return (
<>
<PageHead title={t`Settings | Workspace`} />
@@ -107,19 +80,6 @@ export default function WorkspaceSettings() {
disabled={!canEditWorkspace}
/>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasActiveSubscription(subscriptions, "pro") &&
!hasActiveSubscription(subscriptions, "team") && (
<div className="my-8">
<Button
onClick={() => openModal("UPGRADE_TO_PRO")}
iconRight={<HiBolt />}
>
{t`Upgrade to Pro`}
</Button>
</div>
)}
<div className="border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Delete workspace`}
@@ -146,16 +106,6 @@ export default function WorkspaceSettings() {
>
<DeleteWorkspaceConfirmation />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"}
>
<UpgradeToProConfirmation
userId={data?.id ?? ""}
workspacePublicId={workspace.publicId}
/>
</Modal>
{/* Global modals */}
<Modal
modalSize="md"

View File

@@ -172,7 +172,7 @@ export default function Avatar({
method: "POST",
headers: {
"Content-Type": blob.type,
"x-original-filename": fileName,
"x-original-filename": encodeURIComponent(fileName),
},
body: blob,
},

View File

@@ -2,7 +2,6 @@ import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useMutation } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -14,27 +13,43 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const FormSchema = z
.object({
currentPassword: z.string().min(1, t`Current password is required`),
const buildSchema = (hasPassword: boolean) => {
const base = z.object({
currentPassword: hasPassword
? z.string().min(1, t`Current password is required`)
: z.string().optional(),
newPassword: z
.string()
.min(8, t`Password must be at least 8 characters`)
.min(1, t`New password is required`),
confirmPassword: z.string().min(1, t`Please confirm your new password`),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: t`Passwords do not match`,
path: ["confirmPassword"],
})
.refine((data) => data.currentPassword !== data.newPassword, {
message: t`New password must be different from current password`,
path: ["newPassword"],
});
type FormValues = z.infer<typeof FormSchema>;
return base
.refine((data) => data.newPassword === data.confirmPassword, {
message: t`Passwords do not match`,
path: ["confirmPassword"],
})
.refine(
(data) => !hasPassword || data.currentPassword !== data.newPassword,
{
message: t`New password must be different from current password`,
path: ["newPassword"],
},
);
};
export function ChangePasswordFormConfirmation() {
interface FormValues {
currentPassword?: string;
newPassword: string;
confirmPassword: string;
}
interface Props {
hasPassword: boolean;
}
export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const router = useRouter();
@@ -47,15 +62,24 @@ export function ChangePasswordFormConfirmation() {
reset,
setError,
} = useForm<FormValues>({
resolver: zodResolver(FormSchema),
resolver: zodResolver(buildSchema(hasPassword)),
mode: "onChange",
});
const setPasswordMutation = api.user.setPassword.useMutation();
const changePasswordMutation = useMutation({
mutationFn: async (data: FormValues) => {
if (!hasPassword) {
await setPasswordMutation.mutateAsync({
newPassword: data.newPassword,
});
return;
}
const response = await authClient.changePassword({
newPassword: data.newPassword,
currentPassword: data.currentPassword,
currentPassword: data.currentPassword ?? "",
revokeOtherSessions: true,
});
@@ -66,12 +90,18 @@ export function ChangePasswordFormConfirmation() {
onSuccess: async () => {
closeModal();
showPopup({
header: t`Password Changed`,
message: t`Your password has been changed.`,
header: hasPassword ? t`Password Changed` : t`Password Set`,
message: hasPassword
? t`Your password has been changed.`
: t`Your password has been set.`,
icon: "success",
});
utils.invalidate();
if (!hasPassword) {
sessionStorage.removeItem("set_password_prompted");
}
utils.user.getUser.invalidate();
reset();
router.push("/");
},
@@ -86,7 +116,9 @@ export function ChangePasswordFormConfirmation() {
} else {
closeModal();
showPopup({
header: t`Error Changing Password`,
header: hasPassword
? t`Error Changing Password`
: t`Error Setting Password`,
message: t`An unexpected error occurred. Please try again later.`,
icon: "error",
});
@@ -106,27 +138,33 @@ export function ChangePasswordFormConfirmation() {
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium dark:text-white">{t`Change Password`}</h2>
<h2 className="pb-4 text-base font-medium dark:text-white">
{hasPassword ? t`Change Password` : t`Set Password`}
</h2>
<p className="mb-4 text-sm text-light-900">
{t`Enter your current password and choose a new secure password.`}
{hasPassword
? t`Enter your current password and choose a new secure password.`
: t`You signed in without a password. Set a password to enable password-based login.`}
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-2">
<div>
<Input
id="currentPassword"
type="password"
{...register("currentPassword")}
placeholder={t`Enter your current password`}
/>
{errors.currentPassword && (
<p className="mt-2 text-xs text-red-400">
{errors.currentPassword.message}
</p>
)}
</div>
{hasPassword && (
<div>
<Input
id="currentPassword"
type="password"
{...register("currentPassword")}
placeholder={t`Enter your current password`}
/>
{errors.currentPassword && (
<p className="mt-2 text-xs text-red-400">
{errors.currentPassword.message}
</p>
)}
</div>
)}
<div>
<Input
@@ -175,7 +213,7 @@ export function ChangePasswordFormConfirmation() {
fullWidth
size="lg"
>
{t`Change Password`}
{hasPassword ? t`Change Password` : t`Set Password`}
</Button>
</div>
</form>

View File

@@ -13,20 +13,29 @@ interface DeleteWebhookConfirmationProps {
export function DeleteWebhookConfirmation({
workspacePublicId,
}: DeleteWebhookConfirmationProps) {
const { closeModal, entityId: webhookPublicId, entityLabel: webhookName } = useModal();
const {
closeModal,
entityId: webhookPublicId,
entityLabel: webhookName,
} = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const deleteWebhookMutation = api.webhook.delete.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook deleted successfully`, type: "success" });
showPopup({
header: t`Webhook deleted`,
message: t`Webhook deleted successfully`,
icon: "success",
});
closeModal();
},
onError: (error) => {
showPopup({
header: t`Unable to delete webhook`,
message: error.message || t`Failed to delete webhook`,
type: "error",
icon: "error",
});
},
});
@@ -35,7 +44,7 @@ export function DeleteWebhookConfirmation({
if (!webhookPublicId) return;
deleteWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
webhookPublicId: webhookPublicId,
});
};

View File

@@ -44,7 +44,12 @@ export function NewWebhookModal({
workspacePublicId,
isEdit = false,
}: NewWebhookModalProps) {
const { closeModal, entityId: webhookPublicId, getModalState, clearModalState } = useModal();
const {
closeModal,
entityId: webhookPublicId,
getModalState,
clearModalState,
} = useModal();
const { showPopup } = usePopup();
const [isTestingWebhook, setIsTestingWebhook] = useState(false);
@@ -101,13 +106,18 @@ export function NewWebhookModal({
const createWebhookMutation = api.webhook.create.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook created successfully`, type: "success" });
showPopup({
header: t`Webhook created`,
message: t`Webhook created successfully`,
icon: "success",
});
closeModal();
},
onError: (error) => {
showPopup({
header: t`Unable to create webhook`,
message: error.message || t`Failed to create webhook`,
type: "error",
icon: "error",
});
},
});
@@ -115,13 +125,18 @@ export function NewWebhookModal({
const updateWebhookMutation = api.webhook.update.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook updated successfully`, type: "success" });
showPopup({
header: t`Webhook updated`,
message: t`Webhook updated successfully`,
icon: "success",
});
closeModal();
},
onError: (error) => {
showPopup({
header: t`Unable to update webhook`,
message: error.message || t`Failed to update webhook`,
type: "error",
icon: "error",
});
},
});
@@ -129,19 +144,25 @@ export function NewWebhookModal({
const testWebhookMutation = api.webhook.test.useMutation({
onSuccess: (result) => {
if (result.success) {
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
showPopup({
header: t`Test sent`,
message: t`Test webhook sent successfully!`,
icon: "success",
});
} else {
showPopup({
header: t`Test failed`,
message: result.error || t`Webhook test failed`,
type: "error",
icon: "error",
});
}
setIsTestingWebhook(false);
},
onError: (error) => {
showPopup({
header: t`Unable to test webhook`,
message: error.message || t`Failed to test webhook`,
type: "error",
icon: "error",
});
setIsTestingWebhook(false);
},
@@ -151,7 +172,7 @@ export function NewWebhookModal({
if (isEdit && webhookPublicId) {
updateWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
webhookPublicId: webhookPublicId,
name: data.name,
url: data.url,
secret: data.secret || undefined,
@@ -174,11 +195,12 @@ export function NewWebhookModal({
setIsTestingWebhook(true);
testWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
webhookPublicId: webhookPublicId,
});
};
const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending;
const isPending =
createWebhookMutation.isPending || updateWebhookMutation.isPending;
return (
<form onSubmit={handleSubmit(onSubmit)}>
@@ -231,7 +253,11 @@ export function NewWebhookModal({
<Input
id="secret"
type="password"
placeholder={isEdit ? t`Enter new secret to update` : t`HMAC secret for signature verification`}
placeholder={
isEdit
? t`Enter new secret to update`
: t`HMAC secret for signature verification`
}
{...register("secret")}
errorMessage={errors.secret?.message}
/>
@@ -252,7 +278,7 @@ export function NewWebhookModal({
{webhookEvents.map((event) => (
<label
key={event}
className="flex items-center space-x-2 cursor-pointer"
className="flex cursor-pointer items-center space-x-2"
>
<input
type="checkbox"
@@ -262,11 +288,11 @@ export function NewWebhookModal({
field.onChange([...field.value, event]);
} else {
field.onChange(
field.value.filter((v) => v !== event)
field.value.filter((v) => v !== event),
);
}
}}
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 dark:border-dark-400"
/>
<span className="text-sm text-light-900 dark:text-dark-900">
{event}
@@ -277,17 +303,19 @@ export function NewWebhookModal({
)}
/>
{errors.events && (
<p className="mt-1 text-xs text-red-500">{errors.events.message}</p>
<p className="mt-1 text-xs text-red-500">
{errors.events.message}
</p>
)}
</div>
{isEdit && (
<div>
<label className="flex items-center space-x-2 cursor-pointer">
<label className="flex cursor-pointer items-center space-x-2">
<input
type="checkbox"
{...register("active")}
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 dark:border-dark-400"
/>
<span className="text-sm text-light-900 dark:text-dark-900">
{t`Active`}

View File

@@ -36,7 +36,7 @@ export default function UpdateWeekStartDayForm({
setValue(newValue);
updateWorkspace.mutate({
workspacePublicId,
weekStartDay: newValue,
weekStartDay: newValue as 0 | 1 | 6,
});
};

View File

@@ -20,7 +20,7 @@ const UpdateWorkspaceUrlForm = ({
}: {
workspacePublicId: string;
workspaceUrl: string;
workspacePlan: "free" | "pro" | "enterprise";
workspacePlan: "free" | "team" | "pro" | "enterprise";
disabled?: boolean;
}) => {
const utils = api.useUtils();

View File

@@ -171,18 +171,20 @@ export default function WebhookList({ workspacePublicId }: WebhookListProps) {
const testWebhookMutation = api.webhook.test.useMutation({
onSuccess: (result) => {
if (result.success) {
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
showPopup({ header: t`Test sent`, message: t`Test webhook sent successfully!`, icon: "success" });
} else {
showPopup({
header: t`Test failed`,
message: result.error || t`Webhook test failed`,
type: "error",
icon: "error",
});
}
},
onError: (error) => {
showPopup({
header: t`Unable to test webhook`,
message: error.message || t`Failed to test webhook`,
type: "error",
icon: "error",
});
},
});

View File

@@ -99,6 +99,19 @@ services:
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
# Partner config (optional)
- NEXT_PUBLIC_PARTNER_NAME=${NEXT_PUBLIC_PARTNER_NAME}
- PARTNER_CLIENT_ID=${PARTNER_CLIENT_ID}
- PARTNER_CLIENT_SECRET=${PARTNER_CLIENT_SECRET}
- PARTNER_REDIRECT_URL=${PARTNER_REDIRECT_URL}
- PARTNER_TOKEN_URL=${PARTNER_TOKEN_URL}
- PARTNER_OAUTH_LICENSE_URL=${PARTNER_OAUTH_LICENSE_URL}
- PARTNER_LICENSE_API_URL=${PARTNER_LICENSE_API_URL}
- PARTNER_API_KEY=${PARTNER_API_KEY}
- PARTNER_API_KEY_HEADER=${PARTNER_API_KEY_HEADER}
- PARTNER_SIGNATURE_HEADER=${PARTNER_SIGNATURE_HEADER}
- PARTNER_TIMESTAMP_HEADER=${PARTNER_TIMESTAMP_HEADER}
depends_on:
migrator:
condition: service_completed_successfully

View File

@@ -5,36 +5,40 @@
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./root": {
"types": "./dist/root.d.ts",
"types": "./src/root.ts",
"default": "./src/root.ts"
},
"./trpc": {
"types": "./dist/trpc.d.ts",
"types": "./src/trpc.ts",
"default": "./src/trpc.ts"
},
"./types": {
"types": "./dist/types.d.ts",
"types": "./src/types/router.types.ts",
"default": "./src/types/router.types.ts"
},
"./openapi": {
"types": "./dist/openapi.d.ts",
"types": "./src/openapi.ts",
"default": "./src/openapi.ts"
},
"./utils/rateLimit": {
"types": "./dist/utils/rateLimit.d.ts",
"types": "./src/utils/rateLimit.ts",
"default": "./src/utils/rateLimit.ts"
},
"./utils/apiLogging": {
"types": "./dist/utils/apiLogging.d.ts",
"types": "./src/utils/apiLogging.ts",
"default": "./src/utils/apiLogging.ts"
},
"./utils/permissions": {
"types": "./dist/utils/permissions.d.ts",
"types": "./src/utils/permissions.ts",
"default": "./src/utils/permissions.ts"
},
"./utils/workspace": {
"types": "./src/utils/workspace.ts",
"default": "./src/utils/workspace.ts"
}
},
"license": "GPL-3.0",

View File

@@ -8,20 +8,24 @@ import * as checklistRepo from "@kan/db/repository/checklist.repo";
import * as labelRepo from "@kan/db/repository/label.repo";
import * as listRepo from "@kan/db/repository/list.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
cardCreateResponseSchema,
cardUpdateResponseSchema,
cardDetailSchema,
commentResponseSchema,
commentDeleteResponseSchema,
activityItemSchema,
cardCreateResponseSchema,
cardDetailSchema,
cardUpdateResponseSchema,
commentDeleteResponseSchema,
commentResponseSchema,
} from "../schemas";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { mergeActivities } from "../utils/activities";
import { sendMentionEmails } from "../utils/notifications";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
import {
assertCanDelete,
assertCanEdit,
assertPermission,
} from "../utils/permissions";
import {
createCardWebhookPayload,
sendWebhooksForWorkspace,
@@ -78,6 +82,7 @@ export const cardRouter = createTRPCRouter({
description: input.description,
createdBy: userId,
listId: list.id,
workspaceId: list.workspaceId,
position: input.position,
dueDate: input.dueDate ?? null,
});
@@ -186,6 +191,7 @@ export const cardRouter = createTRPCRouter({
"card.created",
{
id: String(newCard.id),
publicId: newCard.publicId,
title: input.title,
description: input.description,
dueDate: input.dueDate ?? null,
@@ -244,7 +250,12 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, card.workspaceId, "comment:create");
await assertPermission(
ctx.db,
userId,
card.workspaceId,
"comment:create",
);
const newComment = await cardCommentRepo.create(ctx.db, {
comment: input.comment,
@@ -322,7 +333,7 @@ export const cardRouter = createTRPCRouter({
input.commentPublicId,
);
if (!existingComment)
if (!existingComment || existingComment.cardId !== card.id)
throw new TRPCError({
message: `Comment with public ID ${input.commentPublicId} not found`,
code: "NOT_FOUND",
@@ -410,7 +421,7 @@ export const cardRouter = createTRPCRouter({
input.commentPublicId,
);
if (!existingComment)
if (!existingComment || existingComment.cardId !== card.id)
throw new TRPCError({
message: `Comment with public ID ${input.commentPublicId} not found`,
code: "NOT_FOUND",
@@ -899,10 +910,7 @@ export const cardRouter = createTRPCRouter({
| undefined;
if (input.listPublicId) {
newList = await listRepo.getByPublicId(
ctx.db,
input.listPublicId,
);
newList = await listRepo.getByPublicId(ctx.db, input.listPublicId);
if (!newList)
throw new TRPCError({
@@ -944,7 +952,7 @@ export const cardRouter = createTRPCRouter({
);
}
if (input.index !== undefined) {
if (input.index !== undefined || newListId !== undefined) {
result = await cardRepo.reorder(ctx.db, {
cardId: existingCard.id,
newIndex: input.index,
@@ -1046,12 +1054,14 @@ export const cardRouter = createTRPCRouter({
) {
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
}
const movedToNewList = Boolean(newListId && existingCard.listId !== newListId);
const movedToNewList = Boolean(
newListId && existingCard.listId !== newListId,
);
const currentWebhookListPublicId = movedToNewList
? input.listPublicId!
: existingCard.list.publicId;
const currentWebhookListName = movedToNewList
? newList?.name ?? card.listName
? (newList?.name ?? card.listName)
: existingCard.list.name;
if (movedToNewList) {
@@ -1069,6 +1079,7 @@ export const cardRouter = createTRPCRouter({
movedToNewList ? "card.moved" : "card.updated",
{
id: String(result.id),
publicId: result.publicId,
title: result.title,
description: result.description,
dueDate: result.dueDate,
@@ -1164,6 +1175,7 @@ export const cardRouter = createTRPCRouter({
"card.deleted",
{
id: String(fullCard.id),
publicId: fullCard.publicId,
title: fullCard.title,
description: fullCard.description,
dueDate: fullCard.dueDate,
@@ -1272,6 +1284,7 @@ export const cardRouter = createTRPCRouter({
description: sourceCard.description ?? "",
createdBy: userId,
listId: targetList.id,
workspaceId: targetList.workspaceId,
position: "end",
dueDate: sourceCard.dueDate ?? null,
});
@@ -1286,7 +1299,10 @@ export const cardRouter = createTRPCRouter({
if (input.copyLabels && sourceCard.labels?.length) {
const labelPublicIds = sourceCard.labels.map((l) => l.publicId);
const labels = await labelRepo.getAllByPublicIds(ctx.db, labelPublicIds);
const labels = await labelRepo.getAllByPublicIds(
ctx.db,
labelPublicIds,
);
if (labels.length) {
const labelsInsert = labels.map((label) => ({
cardId: newCard.id,
@@ -1342,7 +1358,7 @@ export const cardRouter = createTRPCRouter({
checklistId: newChecklist.id,
title: item.title,
createdBy: userId,
completed: item.completed ?? false,
completed: false,
});
}
}

View File

@@ -340,6 +340,7 @@ export const importRouter = createTRPCRouter({
description: card.description,
createdBy: userId,
listId: newListId,
workspaceId: workspace.id,
index,
importId: newImportId,
}));
@@ -888,6 +889,7 @@ export const importRouter = createTRPCRouter({
description: data.description,
createdBy: userId,
listId: data.listId,
workspaceId: workspace.id,
index: index,
importId: newImportId,
}));

View File

@@ -10,13 +10,14 @@ import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import {
generateUID,
getSeatLimit,
getSubscriptionByPlan,
hasUnlimitedSeats,
} from "@kan/shared";
import { updateSubscriptionSeats } from "@kan/stripe";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { memberInviteResponseSchema } from "../schemas";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
assertCanManageMember,
assertCanManageRole,
@@ -114,6 +115,20 @@ export const memberRouter = createTRPCRouter({
});
}
}
const seatLimit = getSeatLimit(subscriptions);
if (seatLimit !== null) {
const memberCount = await memberRepo.getCountByWorkspaceId(
ctx.db,
workspace.id,
);
if (memberCount >= seatLimit) {
throw new TRPCError({
message: `SEAT_LIMIT_REACHED`,
code: "FORBIDDEN",
});
}
}
}
const existingUser = await userRepo.getByEmail(ctx.db, input.email);
@@ -644,6 +659,20 @@ export const memberRouter = createTRPCRouter({
});
}
}
const seatLimit = getSeatLimit(subscriptions);
if (seatLimit !== null) {
const memberCount = await memberRepo.getCountByWorkspaceId(
ctx.db,
workspace.id,
);
if (memberCount >= seatLimit) {
throw new TRPCError({
message: `SEAT_LIMIT_REACHED`,
code: "FORBIDDEN",
});
}
}
}
// Get the workspace role to set roleId

View File

@@ -2,9 +2,9 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as userRepo from "@kan/db/repository/user.repo";
import { generateAvatarUrl } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { generateAvatarUrl } from "@kan/shared/utils";
export const userRouter = createTRPCRouter({
getUser: protectedProcedure
@@ -27,6 +27,8 @@ export const userRouter = createTRPCRouter({
name: z.string().nullable(),
image: z.string().nullable(),
stripeCustomerId: z.string().nullable(),
hasPassword: z.boolean(),
hasMagicLinkAccount: z.boolean(),
apiKey: z
.object({
id: z.number(),
@@ -61,6 +63,8 @@ export const userRouter = createTRPCRouter({
return {
...result,
image: imageUrl,
hasPassword: result.hasPassword,
hasMagicLinkAccount: result.hasMagicLinkAccount,
apiKey: apiKey ? { id: apiKey.id, prefix: apiKey.prefix } : null,
};
}),
@@ -114,4 +118,60 @@ export const userRouter = createTRPCRouter({
image: imageUrl,
};
}),
setPassword: protectedProcedure
.meta({
openapi: {
method: "POST",
path: "/users/me/password",
summary: "Set password",
description:
"Sets a password for a user who signed up via magic link and has no password yet",
tags: ["Users"],
protect: true,
},
})
.input(
z.object({
newPassword: z
.string()
.min(8, "Password must be at least 8 characters"),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const existing = await userRepo.getById(ctx.db, userId);
if (!existing) {
throw new TRPCError({
message: `User not found`,
code: "NOT_FOUND",
});
}
if (existing.hasPassword) {
throw new TRPCError({
message: `Password already set; use change password instead`,
code: "BAD_REQUEST",
});
}
try {
await ctx.auth.api.setPassword({ newPassword: input.newPassword });
} catch {
throw new TRPCError({
message: "Failed to set password",
code: "INTERNAL_SERVER_ERROR",
});
}
return { success: true };
}),
});

View File

@@ -334,6 +334,7 @@ export const webhookRouter = createTRPCRouter({
const testPayload = createCardWebhookPayload("card.created", {
id: "test-card-id",
publicId: "test-card-public-id",
title: "Test Card",
description: "This is a test webhook payload",
dueDate: null,

View File

@@ -2,19 +2,21 @@ import { TRPCError } from "@trpc/server";
import { env } from "next-runtime-env";
import { z } from "zod";
import type { WorkspacePlan } from "@kan/db/schema";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
workspaceListItemSchema,
workspaceDetailSchema,
workspaceWithBoardsSchema,
workspaceCreateResponseSchema,
workspaceUpdateResponseSchema,
workspaceDeleteResponseSchema,
workspaceDetailSchema,
workspaceListItemSchema,
workspaceUpdateResponseSchema,
workspaceWithBoardsSchema,
} from "../schemas";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
export const workspaceRouter = createTRPCRouter({
@@ -270,12 +272,48 @@ export const workspaceRouter = createTRPCRouter({
code: "INTERNAL_SERVER_ERROR",
});
let unlinkedSlot: Awaited<
ReturnType<typeof subscriptionRepo.getFirstUnlinkedSlotByLicenseKey>
>;
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
const memberships = await workspaceRepo.getAllByUserId(ctx.db, userId);
const otherWorkspaceIds = memberships
.map((m) => m.workspace?.publicId)
.filter((id): id is string => !!id && id !== workspacePublicId);
const partnerSub = otherWorkspaceIds.length
? await subscriptionRepo.getFirstActivePartnerSubByWorkspaceIds(
ctx.db,
otherWorkspaceIds,
)
: undefined;
unlinkedSlot = partnerSub?.partnerLicenseKey
? await subscriptionRepo.getFirstUnlinkedSlotByLicenseKey(
ctx.db,
partnerSub.partnerLicenseKey,
)
: undefined;
if (unlinkedSlot) {
await Promise.all([
subscriptionRepo.updateById(ctx.db, unlinkedSlot.id, {
referenceId: workspacePublicId,
}),
workspaceRepo.update(ctx.db, workspacePublicId, {
plan: unlinkedSlot.plan as WorkspacePlan,
}),
]);
}
}
return {
publicId: result.publicId,
name: result.name!,
slug: result.slug!,
description: result.description ?? null,
plan: result.plan!,
plan: (unlinkedSlot?.plan ?? result.plan!) as WorkspacePlan,
cardPrefix: result.cardPrefix!,
};
}),
update: protectedProcedure
@@ -411,10 +449,21 @@ export const workspaceRouter = createTRPCRouter({
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
await workspaceRepo.hardDelete(
ctx.db,
input.workspacePublicId,
);
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
const subs = await subscriptionRepo.getByReferenceId(
ctx.db,
input.workspacePublicId,
);
await Promise.all(
subs
.filter((s) => !!s.partnerLicenseKey)
.map((s) =>
subscriptionRepo.updateById(ctx.db, s.id, { referenceId: null }),
),
);
}
await workspaceRepo.hardDelete(ctx.db, input.workspacePublicId);
return { success: true };
}),
@@ -521,6 +570,7 @@ export const workspaceRouter = createTRPCRouter({
boardPublicId: z.string(),
boardName: z.string(),
listName: z.string(),
cardNumber: z.number().nullable(),
updatedAt: z.date().nullable(),
createdAt: z.date(),
type: z.literal("card"),
@@ -558,4 +608,38 @@ export const workspaceRouter = createTRPCRouter({
return result;
}),
hasAvailablePartnerSlot: protectedProcedure
.input(z.void())
.output(z.boolean())
.query(async ({ ctx }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const memberships = await workspaceRepo.getAllByUserId(ctx.db, userId);
const workspaceIds = memberships
.map((m) => m.workspace?.publicId)
.filter((id): id is string => !!id);
if (!workspaceIds.length) return false;
const partnerSub =
await subscriptionRepo.getFirstActivePartnerSubByWorkspaceIds(
ctx.db,
workspaceIds,
);
if (!partnerSub?.partnerLicenseKey) return false;
const unlinkedSlot =
await subscriptionRepo.getFirstUnlinkedSlotByLicenseKey(
ctx.db,
partnerSub.partnerLicenseKey,
);
return !!unlinkedSlot;
}),
});

View File

@@ -39,6 +39,7 @@ const boardDetailCardSchema = z.object({
title: z.string(),
description: z.string().nullable(),
index: z.number(),
cardNumber: z.number().nullable(),
dueDate: z.date().nullable(),
labels: z.array(labelSchema),
members: z.array(boardCardMemberSchema),
@@ -57,6 +58,7 @@ export const boardDetailSchema = z.object({
favorite: z.boolean(),
workspace: z.object({
publicId: z.string(),
cardPrefix: z.string(),
members: z.array(workspaceMemberSchema),
}),
labels: z.array(labelSchema),

View File

@@ -36,6 +36,7 @@ const cardMemberSchema = z.object({
email: z.string(),
user: z
.object({
id: z.string().nullable(),
name: z.string().nullable(),
})
.nullable(),
@@ -45,6 +46,8 @@ export const cardDetailSchema = z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
cardNumber: z.number().nullable(),
index: z.number(),
dueDate: z.date().nullable(),
createdBy: z.string().nullable(),
labels: z.array(labelSchema),
@@ -74,6 +77,7 @@ export const cardDetailSchema = z.object({
),
workspace: z.object({
publicId: z.string(),
cardPrefix: z.string(),
members: z.array(workspaceMemberSchema),
}),
}),
@@ -180,6 +184,7 @@ export const activityItemSchema = z.object({
publicId: z.string(),
user: z
.object({
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string(),
image: z.string().nullable(),
@@ -189,6 +194,7 @@ export const activityItemSchema = z.object({
.nullable(),
user: z
.object({
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string(),
image: z.string().nullable(),

View File

@@ -25,6 +25,7 @@ export const checklistResponseSchema = z.object({
// Shared user sub-object (used inside member/workspace responses)
export const userSchema = z.object({
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string(),
image: z.string().nullable(),

View File

@@ -10,8 +10,9 @@ export const workspaceListItemSchema = z.object({
name: z.string(),
description: z.string().nullable(),
slug: z.string(),
plan: z.enum(["free", "pro", "enterprise"]),
plan: z.enum(["free", "team", "pro", "enterprise"]),
weekStartDay: z.number().nullable(),
cardPrefix: z.string(),
deletedAt: z.date().nullable(),
}),
});
@@ -25,6 +26,7 @@ const workspaceMemberDetailSchema = z.object({
createdAt: z.date(),
user: z
.object({
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string().optional(),
image: z.string().nullable(),
@@ -37,6 +39,7 @@ const workspaceSubscriptionSchema = z.object({
status: z.string(),
seats: z.number().nullable(),
unlimitedSeats: z.boolean().nullable(),
partnerTier: z.number().nullable(),
periodStart: z.date().nullable(),
periodEnd: z.date().nullable(),
});
@@ -72,7 +75,8 @@ export const workspaceCreateResponseSchema = z.object({
name: z.string(),
slug: z.string(),
description: z.string().nullable(),
plan: z.enum(["free", "pro", "enterprise"]),
plan: z.enum(["free", "team", "pro", "enterprise"]),
cardPrefix: z.string(),
});
// ─── workspace.update ────────────────────────────────────────
@@ -81,7 +85,7 @@ export const workspaceUpdateResponseSchema = z.object({
name: z.string(),
slug: z.string(),
description: z.string().nullable(),
plan: z.enum(["free", "pro", "enterprise"]),
plan: z.enum(["free", "team", "pro", "enterprise"]),
showEmailsToMembers: z.boolean().nullable(),
weekStartDay: z.number().nullable(),
});

View File

@@ -63,6 +63,11 @@ const createAuthWithHeaders = (
headers,
query: { referenceId: input.workspacePublicId },
}),
setPassword: (input: { newPassword: string }) =>
auth.api.setPassword({
headers,
body: { newPassword: input.newPassword },
}),
},
};
};

View File

@@ -43,6 +43,7 @@ describe("webhook utilities", () => {
"card.created",
{
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
listId: "list-456",
},
@@ -55,12 +56,14 @@ describe("webhook utilities", () => {
expect(payload.timestamp).toBe("2024-01-15T12:00:00.000Z");
expect(payload.data.card).toEqual({
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
description: undefined,
dueDate: null,
listId: "list-456",
boardId: "board-789",
});
expect(payload.data.card.publicId).toBe("card-pub-123");
});
it("includes optional card fields when provided", () => {
@@ -69,6 +72,7 @@ describe("webhook utilities", () => {
"card.updated",
{
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
description: "A description",
dueDate,
@@ -88,6 +92,7 @@ describe("webhook utilities", () => {
"card.created",
{
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
listId: "list-456",
},
@@ -108,6 +113,7 @@ describe("webhook utilities", () => {
"card.created",
{
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
listId: "list-456",
},
@@ -128,6 +134,7 @@ describe("webhook utilities", () => {
"card.created",
{
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
listId: "list-456",
},
@@ -151,6 +158,7 @@ describe("webhook utilities", () => {
"card.updated",
{
id: "card-123",
publicId: "card-pub-123",
title: "Updated Title",
listId: "list-456",
},
@@ -172,6 +180,7 @@ describe("webhook utilities", () => {
"card.moved",
{
id: "card-123",
publicId: "card-pub-123",
title: "Moved Card",
listId: "list-public-done",
},
@@ -212,6 +221,7 @@ describe("webhook utilities", () => {
data: {
card: {
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
listId: "list-456",
boardId: "board-789",
@@ -408,6 +418,7 @@ describe("webhook utilities", () => {
data: {
card: {
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
listId: "list-456",
boardId: "board-789",

View File

@@ -16,6 +16,7 @@ export interface WebhookPayload {
data: {
card: {
id: string;
publicId: string;
title: string;
description?: string | null;
dueDate?: string | null; // ISO string after JSON serialization
@@ -221,6 +222,7 @@ export function createCardWebhookPayload(
event: WebhookEventType,
card: {
id: string;
publicId: string;
title: string;
description?: string | null;
dueDate?: Date | null;
@@ -243,6 +245,7 @@ export function createCardWebhookPayload(
data: {
card: {
id: card.id,
publicId: card.publicId,
title: card.title,
description: card.description,
dueDate: card.dueDate?.toISOString() ?? null,

View File

@@ -0,0 +1,40 @@
import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateUID } from "@kan/shared/utils";
export const cancelWorkspaceAccess = async (
db: dbClient,
workspacePublicId: string,
): Promise<void> => {
const workspace = await workspaceRepo.getByPublicId(db, workspacePublicId);
if (!workspace) return;
const preserveUserId = await memberRepo.getPreservableMemberId(
db,
workspace.id,
workspace.createdBy ?? null,
);
let newSlug = workspace.publicId;
if (workspace.slug !== workspace.publicId) {
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
db,
workspace.publicId,
);
if (!isPublicIdAvailable) {
newSlug = generateUID();
}
}
await Promise.all([
preserveUserId
? memberRepo.pauseMembersExcept(db, workspace.id, preserveUserId)
: memberRepo.pauseAllMembers(db, workspace.id),
workspaceRepo.update(db, workspacePublicId, {
plan: "free",
slug: newSlug,
}),
]);
};

View File

@@ -10,13 +10,49 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { sendEmail } from "@kan/email";
import { createLogger } from "@kan/logger";
import { generateUID } from "@kan/shared/utils";
const log = createLogger("auth");
import { createStripeClient } from "@kan/stripe";
import { socialProvidersPlugin } from "./providers";
import { triggerWorkflow } from "./utils";
const log = createLogger("auth");
async function cancelWorkspaceAccess(
db: dbClient,
workspacePublicId: string,
): Promise<void> {
const workspace = await workspaceRepo.getByPublicId(db, workspacePublicId);
if (!workspace) return;
const preserveUserId = await memberRepo.getPreservableMemberId(
db,
workspace.id,
workspace.createdBy ?? null,
);
let newSlug = workspace.publicId;
if (workspace.slug !== workspace.publicId) {
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
db,
workspace.publicId,
);
if (!isPublicIdAvailable) {
newSlug = generateUID();
}
}
await Promise.all([
preserveUserId
? memberRepo.pauseMembersExcept(db, workspace.id, preserveUserId)
: memberRepo.pauseAllMembers(db, workspace.id),
workspaceRepo.update(db, workspacePublicId, {
plan: "free",
slug: newSlug,
}),
]);
}
export function createPlugins(db: dbClient) {
return [
socialProvidersPlugin(),
@@ -104,7 +140,10 @@ export function createPlugins(db: dbClient) {
unlimitedSeats: true,
},
);
log.info({ subscriptionId: stripeSubscription.id }, "Pro subscription activated with unlimited seats");
log.info(
{ subscriptionId: stripeSubscription.id },
"Pro subscription activated with unlimited seats",
);
const workspace = await workspaceRepo.getByPublicId(
db,
@@ -126,35 +165,9 @@ export function createPlugins(db: dbClient) {
subscription,
cancellationDetails,
);
// for cancelled subscriptions, we need to pause all members and set their workspace plan to free
const workspace = await workspaceRepo.getByPublicId(
db,
subscription.referenceId,
);
if (workspace?.id) {
await memberRepo.pauseAllMembers(db, workspace.id);
// Reset slug to publicId, or generate a UID if publicId is taken
let newSlug = workspace.publicId;
if (workspace.slug !== workspace.publicId) {
const isPublicIdAvailable =
await workspaceRepo.isWorkspaceSlugAvailable(
db,
workspace.publicId,
);
if (!isPublicIdAvailable) {
newSlug = generateUID();
}
}
await workspaceRepo.update(db, subscription.referenceId, {
plan: "free",
slug: newSlug,
});
}
},
onSubscriptionDeleted: async ({ subscription }) => {
await cancelWorkspaceAccess(db, subscription.referenceId);
},
onSubscriptionUpdate: async ({ subscription }) => {
await triggerWorkflow(db, "subscription-updated", subscription);
@@ -170,7 +183,7 @@ export function createPlugins(db: dbClient) {
if (authorization?.startsWith("Bearer ")) {
return authorization.slice(7);
}
return ctx.headers?.get("x-api-key") ?? undefined;
return ctx.headers?.get("x-api-key") ?? null;
},
rateLimit: {
enabled: true,
@@ -183,7 +196,10 @@ export function createPlugins(db: dbClient) {
sendMagicLink: async ({ email, url }) => {
try {
const decodedUrl = decodeURIComponent(url);
log.info({ email, isInvite: decodedUrl.includes("type=invite") }, "Sending magic link");
log.info(
{ email, isInvite: decodedUrl.includes("type=invite") },
"Sending magic link",
);
if (decodedUrl.includes("type=invite")) {
let inviterName = "";
let workspaceName = "";

View File

@@ -0,0 +1,47 @@
ALTER TABLE "card" ADD COLUMN "cardNumber" integer;--> statement-breakpoint
ALTER TABLE "workspace" ADD COLUMN "cardPrefix" varchar(10) DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "workspace" ADD COLUMN "cardCounter" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "card_list_number_idx" ON "card" USING btree ("listId","cardNumber");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_card_prefix_idx" ON "workspace" USING btree ("cardPrefix");--> statement-breakpoint
-- Populate cardPrefix for existing workspaces from their name
UPDATE "workspace"
SET "cardPrefix" = (
SELECT CASE
WHEN array_length(words, 1) = 1 THEN UPPER(LEFT(words[1], 3))
ELSE UPPER(LEFT(array_to_string(ARRAY(
SELECT LEFT(w, 1) FROM unnest(words) AS w WHERE w != ''
), ''), 4))
END
FROM (
SELECT regexp_split_to_array(trim("name"), '\s+') AS words
) sub
)
WHERE "cardPrefix" = '';--> statement-breakpoint
-- Assign sequential cardNumber to existing cards (including soft-deleted),
-- ordered by createdAt, scoped to workspace. Deleted cards still receive a
-- number so future un-archive/restore flows can keep their ticket ID.
WITH numbered AS (
SELECT
c.id,
ROW_NUMBER() OVER (PARTITION BY b."workspaceId" ORDER BY c."createdAt", c.id) AS rn
FROM "card" c
JOIN "list" l ON c."listId" = l.id
JOIN "board" b ON l."boardId" = b.id
WHERE c."cardNumber" IS NULL
)
UPDATE "card" c
SET "cardNumber" = n.rn
FROM numbered n
WHERE c.id = n.id;--> statement-breakpoint
-- Update cardCounter on each workspace to the max cardNumber assigned
UPDATE "workspace" w
SET "cardCounter" = COALESCE((
SELECT MAX(c."cardNumber")
FROM "card" c
JOIN "list" l ON c."listId" = l.id
JOIN "board" b ON l."boardId" = b.id
WHERE b."workspaceId" = w.id AND c."cardNumber" IS NOT NULL
), 0);

View File

@@ -0,0 +1,3 @@
ALTER TABLE "subscription" ADD COLUMN "partnerLicenseKey" varchar(255);--> statement-breakpoint
ALTER TABLE "subscription" ADD COLUMN "partnerTier" integer;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "subscription_partner_license_key_idx" ON "subscription" USING btree ("partnerLicenseKey");

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "subscription_partner_license_key_idx";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -225,6 +225,27 @@
"when": 1775170588247,
"tag": "20260402225628_AddTeamWorkspacePlan",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1776809379931,
"tag": "20260421220939_AddCardNumber",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1778617946519,
"tag": "20260512203226_AddPartnerLicenseToSubscription",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1780057951781,
"tag": "20260529123231_DropPartnerLicenseKeyUniqueConstraint",
"breakpoints": true
}
]
}

View File

@@ -5,27 +5,27 @@
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./client": {
"types": "./dist/client.d.ts",
"types": "./src/client.ts",
"default": "./src/client.ts"
},
"./schema": {
"types": "./dist/schema.d.ts",
"types": "./src/schema/index.ts",
"default": "./src/schema/index.ts"
},
"./types/*": {
"types": "./dist/types/*.d.ts",
"types": "./src/types/*",
"default": "./src/types/*"
},
"./repository/*": {
"types": "./dist/repository/*.d.ts",
"types": "./src/repository/*.ts",
"default": "./src/repository/*.ts"
},
"./redis": {
"types": "./dist/redis.d.ts",
"types": "./src/redis.ts",
"default": "./src/redis.ts"
}
},

View File

@@ -209,6 +209,7 @@ export const getByPublicId = async (
workspace: {
columns: {
publicId: true,
cardPrefix: true,
},
with: {
members: {
@@ -220,6 +221,7 @@ export const getByPublicId = async (
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
@@ -254,6 +256,7 @@ export const getByPublicId = async (
listId: true,
index: true,
dueDate: true,
cardNumber: true,
},
with: {
labels: {
@@ -278,6 +281,7 @@ export const getByPublicId = async (
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
@@ -422,6 +426,7 @@ export const getBySlug = async (
publicId: true,
name: true,
slug: true,
cardPrefix: true,
},
},
labels: {
@@ -448,6 +453,7 @@ export const getBySlug = async (
listId: true,
index: true,
dueDate: true,
cardNumber: true,
},
with: {
labels: {

View File

@@ -22,6 +22,7 @@ import {
labels,
lists,
workspaceMembers,
workspaces,
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
@@ -41,6 +42,7 @@ export const create = async (
description: string;
createdBy: string;
listId: number;
workspaceId: number;
position: "start" | "end";
dueDate?: Date | null;
},
@@ -82,6 +84,17 @@ export const create = async (
`);
}
const [counterResult] = await tx
.update(workspaces)
.set({ cardCounter: sql`${workspaces.cardCounter} + 1` })
.where(eq(workspaces.id, cardInput.workspaceId))
.returning({ cardCounter: workspaces.cardCounter });
if (!counterResult)
throw new Error(`Workspace ${cardInput.workspaceId} not found`);
const cardNumber = counterResult.cardCounter;
const result = await tx
.insert(cards)
.values({
@@ -91,9 +104,15 @@ export const create = async (
createdBy: cardInput.createdBy,
listId: cardInput.listId,
index: index,
cardNumber,
dueDate: cardInput.dueDate ?? null,
})
.returning({ id: cards.id, listId: cards.listId, publicId: cards.publicId });
.returning({
id: cards.id,
listId: cards.listId,
publicId: cards.publicId,
cardNumber: cards.cardNumber,
});
if (!result[0]) throw new Error("Unable to create card");
@@ -273,6 +292,7 @@ export const bulkCreate = async (
description: string;
createdBy: string;
listId: number;
workspaceId: number;
index: number;
importId?: number;
}[],
@@ -288,6 +308,33 @@ export const bulkCreate = async (
byList.set(item.listId, arr);
}
// Atomically reserve a contiguous range of cardNumbers per workspace by
// bumping cardCounter once per workspace.
const countsByWorkspace = new Map<number, number>();
for (const item of cardInput) {
countsByWorkspace.set(
item.workspaceId,
(countsByWorkspace.get(item.workspaceId) ?? 0) + 1,
);
}
const cardNumberByWorkspaceQueue = new Map<number, number[]>();
for (const [workspaceId, count] of countsByWorkspace.entries()) {
const [counterResult] = await tx
.update(workspaces)
.set({ cardCounter: sql`${workspaces.cardCounter} + ${count}` })
.where(eq(workspaces.id, workspaceId))
.returning({ cardCounter: workspaces.cardCounter });
if (!counterResult) throw new Error(`Workspace ${workspaceId} not found`);
const last = counterResult.cardCounter;
const start = last - count + 1;
const queue: number[] = [];
for (let n = start; n <= last; n++) queue.push(n);
cardNumberByWorkspaceQueue.set(workspaceId, queue);
}
const allValuesToInsert: {
publicId: string;
title: string;
@@ -295,6 +342,7 @@ export const bulkCreate = async (
createdBy: string;
listId: number;
index: number;
cardNumber: number;
importId?: number;
}[] = [];
@@ -309,6 +357,12 @@ export const bulkCreate = async (
let nextIndex = last ? last.index + 1 : 0;
const sorted = [...items].sort((a, b) => a.index - b.index);
for (const it of sorted) {
const queue = cardNumberByWorkspaceQueue.get(it.workspaceId);
const cardNumber = queue?.shift();
if (cardNumber === undefined)
throw new Error(
`Failed to allocate cardNumber for workspace ${it.workspaceId}`,
);
allValuesToInsert.push({
publicId: it.publicId,
title: it.title,
@@ -316,6 +370,7 @@ export const bulkCreate = async (
createdBy: it.createdBy,
listId: it.listId,
index: nextIndex++,
cardNumber,
importId: it.importId,
});
}
@@ -434,6 +489,8 @@ export const getWithListAndMembersByPublicId = async (
description: true,
dueDate: true,
createdBy: true,
cardNumber: true,
index: true,
},
with: {
labels: {
@@ -510,6 +567,7 @@ export const getWithListAndMembersByPublicId = async (
workspace: {
columns: {
publicId: true,
cardPrefix: true,
},
with: {
members: {

View File

@@ -45,6 +45,7 @@ export const getByPublicId = (db: dbClient, publicId: string) => {
publicId: true,
comment: true,
createdBy: true,
cardId: true,
},
where: eq(comments.publicId, publicId),
});

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