Compare commits

...

39 Commits

Author SHA1 Message Date
Henry
aff1d76010 feat: allow submitting comments via keyboard shortcut 2026-07-07 22:24:32 +01:00
hjball
a15e193f66 chore: update translations 2026-06-30 11:00:57 +00:00
Henry
019ecbc364 fix: correct template creation condition in BoardDropdown (#529) 2026-06-30 11:59:43 +01:00
hjball
82b2b4a8e1 chore: compile translations 2026-06-30 10:21:13 +00:00
hjball
c1adb2d4dd chore: update translations 2026-06-30 10:21:09 +00:00
Nick Meinhold
25ed4e39b3 feat: move boards between workspaces (#458)
* feat: add ability to move boards between workspaces

Implements the "Move to workspace" feature (#344) allowing users to
relocate a board and all its contents (lists, cards, labels, checklists,
comments, activity) to a different workspace.

Key design decisions:
- Card member assignments are cleared on move (they reference
  workspace-scoped members that may not exist in the target workspace)
- Comments and activity history are preserved (they reference global
  user IDs, not workspace members)
- Slug conflicts in the target workspace are auto-resolved by
  appending a UID suffix
- Permission model: requires board:edit in source workspace and
  board:create in target workspace
- Templates and archived boards cannot be moved

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

* refactor: consolidate board queries in move mutation

Address review feedback:
- Consolidate 3 separate board queries into a single findFirst()
  that fetches all needed fields (id, name, slug, type, isArchived,
  workspaceId, createdBy)
- Fix slug fallback to use board.name instead of publicId for
  human-readable URLs

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

* fix: filter guest workspaces from move board destination list

Guests typically lack board:create permission in the target workspace,
so showing them as destinations leads to a confusing server rejection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: extract getBoardForMove repo function

Moves the inline board query from the move mutation into the repo
layer, consistent with how every other board mutation fetches data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add unit tests for board.move mutation

10 test cases covering auth, validation, permissions, slug conflict
resolution, and the happy path. Follows webhook.test.ts patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: mark locale files as linguist-generated

GitHub will now auto-collapse compiled translation files (messages.json,
messages.ts, messages.po) in PR diffs and exclude them from language
stats. This makes PRs that touch i18n strings much easier to review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove locale file changes from PR

Reverts locale file diffs and .gitattributes to match main, per review
feedback. The locale changes were unrelated translation updates that
inflated the PR diff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove locale file changes from PR

Per @hjball's review: locale compilation/translations are handled
automatically on merge to main, so this PR shouldn't carry them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: align locale files with upstream/main

Previous removal commit used local main, which had drifted from
upstream. Re-syncing to upstream/main so the PR carries no locale diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(board-move): tighten deletedAt handling per review

Three changes addressing @hjball's review comments, all about the
schema treating deletedAt as optional metadata while the move-board
flow needs it as a load-bearing invariant.

1. getBoardForMove now filters isNull(deletedAt). Moving a tombstoned
   board has no defensible semantics. Replaces the implicit
   "the board exists in the table" check with an explicit
   "the board is not soft-deleted" check.

2. Move-flow's clearing of cardToWorkspaceMembers now spans every
   card under every list ever associated with this board, including
   soft-deleted ones. If we leave member assignments on a deleted
   card and that card is later restored, the assignments would
   resurrect rogue references to workspace members from the OLD
   workspace. Removed the isNull filters on both lists and cards in
   that loop.

3. Move-flow now refuses to move into a soft-deleted target
   workspace. workspaceRepo.getByPublicId did not previously project
   deletedAt; extended its column selection so the call-site guard
   in board.move can check it. (A wider fix to make the repo treat
   deleted-as-not-found across all 14+ callers is left for a
   separate PR — narrow scope here.)

Plus one regression test: throws NOT_FOUND when target workspace is
soft-deleted. All 11 board-move tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-30 11:19:39 +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
83 changed files with 10113 additions and 2135 deletions

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.

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

View File

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

@@ -384,6 +384,9 @@ checksums:
i0ZMQl/message: f739058266a2cb6a3d7cca14de09c69a
i0ZMQl/origin/0/0: 0ad2af981579a62b084b3d41462c5546
i0ZMQl/translation: f739058266a2cb6a3d7cca14de09c69a
wE3hGS/message: 1b3bccc465ad34111956b1c705c52031
wE3hGS/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
wE3hGS/translation: 1b3bccc465ad34111956b1c705c52031
wewm3j/message: 985c0177744ec56d8dac84f66ec284ca
wewm3j/origin/0/0: bcd95e286f12800a3e7ee0a71feb3ca5
wewm3j/translation: 985c0177744ec56d8dac84f66ec284ca
@@ -536,26 +539,34 @@ checksums:
dEgA5A/origin/1/0: a50c4fbde8da6e0a77c4e0aef0b44dea
dEgA5A/origin/2/0: 97bd206a05414e2a571020d64e166093
dEgA5A/origin/3/0: 522726bb62051656dc0d165185d6b451
dEgA5A/origin/4/0: b2059fa4b8b99cc10440da26475b234c
dEgA5A/origin/5/0: 2a382c1cdf010f01a8e95c3e05f6df53
dEgA5A/origin/6/0: d55f532bb5ae247e3a018978be0da463
dEgA5A/origin/7/0: 4b4f55c033add8db69178563832227c6
dEgA5A/origin/8/0: 68450df287fb46ea629e818732c1291a
dEgA5A/origin/9/0: 15ea2dfd5eae4e05b226b1320103476e
dEgA5A/origin/10/0: 03c00909b41be01231cd3e843c8ee44e
dEgA5A/origin/11/0: 0627e0040ca4939c9a57349e98c51797
dEgA5A/origin/12/0: 15792234f408027822ee71ccddde6f9b
dEgA5A/origin/13/0: 5c60161e173f5eae9276d8e4c70b4ebb
dEgA5A/origin/14/0: a1fde3fcce608870830f168f027727ff
dEgA5A/origin/15/0: 83911e3eacbad4583e2b1647a784d154
dEgA5A/origin/16/0: dbad0c8d7863cb41f5495264f82e7081
dEgA5A/origin/4/0: f8e87f3fc3dc63bc7961601feb860fa8
dEgA5A/origin/5/0: b2059fa4b8b99cc10440da26475b234c
dEgA5A/origin/6/0: 2a382c1cdf010f01a8e95c3e05f6df53
dEgA5A/origin/7/0: d55f532bb5ae247e3a018978be0da463
dEgA5A/origin/8/0: 4b4f55c033add8db69178563832227c6
dEgA5A/origin/9/0: 68450df287fb46ea629e818732c1291a
dEgA5A/origin/10/0: 15ea2dfd5eae4e05b226b1320103476e
dEgA5A/origin/11/0: 03c00909b41be01231cd3e843c8ee44e
dEgA5A/origin/12/0: 0627e0040ca4939c9a57349e98c51797
dEgA5A/origin/13/0: 15792234f408027822ee71ccddde6f9b
dEgA5A/origin/14/0: 5c60161e173f5eae9276d8e4c70b4ebb
dEgA5A/origin/15/0: a1fde3fcce608870830f168f027727ff
dEgA5A/origin/16/0: 83911e3eacbad4583e2b1647a784d154
dEgA5A/origin/17/0: dbad0c8d7863cb41f5495264f82e7081
dEgA5A/translation: 2e2a849c2223911717de8caa2c71bade
kryGs%2B/message: bba0beaced7ea954ceb980f2b022ffee
kryGs%2B/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
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
AgE2vR/message: a295a7050379531f9f56ada01c32e8e1
AgE2vR/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
AgE2vR/translation: a295a7050379531f9f56ada01c32e8e1
fEY2vP/message: 91509e2f92b0b3b11330b6983139fdbf
fEY2vP/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
fEY2vP/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
@@ -603,10 +614,10 @@ checksums:
zMquA7/message: 38ff8d574865bb71d2f620bc4ba98c20
zMquA7/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
5EMoSo/translation: d5825bc247cfd3e52e233f7dab8051c6
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
@@ -928,6 +939,9 @@ checksums:
f8fH8W/message: 991b75727b6784c1a063a7462b76186d
f8fH8W/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
f8fH8W/translation: 991b75727b6784c1a063a7462b76186d
Uf%2B1DF/message: e5b4d2df65d6676318682404de4e38d3
Uf%2B1DF/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
Uf%2B1DF/translation: e5b4d2df65d6676318682404de4e38d3
Odv3J6/message: f3cc49ba2dc3f9c33917f8a749d68bf5
Odv3J6/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
Odv3J6/translation: f3cc49ba2dc3f9c33917f8a749d68bf5
@@ -1009,6 +1023,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
@@ -1259,9 +1274,9 @@ checksums:
2POOFK/origin/4/0: 9737a9cef0646ff4bbd942b1924b9a2c
2POOFK/origin/5/0: fadd5a5b6963ec5f261072f7e8d2140b
2POOFK/translation: 0326365539c004f6088656f692602078
8LLt89/translation: 9a78178dc1874543d35153ae325e2811
8LLt89/message: 9a78178dc1874543d35153ae325e2811
8LLt89/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
8LLt89/translation: 9a78178dc1874543d35153ae325e2811
uP4V6I/message: 2d403501f0488ec7d4eac979faf051a2
uP4V6I/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
uP4V6I/translation: 2d403501f0488ec7d4eac979faf051a2
@@ -1485,9 +1500,9 @@ checksums:
"%2BdjOzj/origin/0/0": faa852a0c0e3c81fe948e394029c1ba5
"%2BdjOzj/origin/1/0": 8628ddb6f4ded25c0f17e2cbda8207d3
"%2BdjOzj/translation": ade922db1be6b26bc979565ce5de2bc7
M2zwdn/translation: 4c9c0d43434ad63a70e8f956d1900602
M2zwdn/message: 4c9c0d43434ad63a70e8f956d1900602
M2zwdn/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
M2zwdn/translation: 4c9c0d43434ad63a70e8f956d1900602
qPP4lD/message: 03eeed2d715e770259f722ba48a61ab3
qPP4lD/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
qPP4lD/translation: 03eeed2d715e770259f722ba48a61ab3
@@ -1633,17 +1648,17 @@ checksums:
W%2FElkg/message: 1f527cd6d1ed602930bcaa303f503b51
W%2FElkg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
W%2FElkg/translation: 1f527cd6d1ed602930bcaa303f503b51
NwiNTb/translation: 9840dd63c40b860a9de1488a07b86828
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/translation: c7910e9020804dbe5c7022044c669e68
GnG6Oy/message: c7910e9020804dbe5c7022044c669e68
GnG6Oy/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
GnG6Oy/translation: c7910e9020804dbe5c7022044c669e68
wlQNTg/message: 0932e80cba1e3e0a7f52bb67ff31da32
wlQNTg/origin/0/0: 94d3e20c29bb517dd394711d7524e478
wlQNTg/origin/1/0: db1e755dc7d73e037f425111bf7c30a6
@@ -1670,12 +1685,21 @@ checksums:
vneRvS/message: e228107df80015377112d41f4f155cb3
vneRvS/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
vneRvS/translation: e228107df80015377112d41f4f155cb3
sZ%2FWDz/message: 4ae9f1cf0c53ebe8852574353d83d252
sZ%2FWDz/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
sZ%2FWDz/translation: 4ae9f1cf0c53ebe8852574353d83d252
VvCMyU/message: a70262481fb1e9e3ff2506b5c3efa03d
VvCMyU/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
VvCMyU/translation: a70262481fb1e9e3ff2506b5c3efa03d
51UCsN/message: ce14fde5e972c2b5276c25ddded817c4
51UCsN/origin/0/0: 0eeeb7e68164ff118040918f1ef00a7a
51UCsN/translation: ce14fde5e972c2b5276c25ddded817c4
J4%2BOTA/message: 6ad27e803483208b5313660b7b5791d3
J4%2BOTA/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
J4%2BOTA/translation: 6ad27e803483208b5313660b7b5791d3
S3wq2O/message: 9268b9b2e31373e5ac2e29c19d08e1ef
S3wq2O/origin/0/0: 0ad2af981579a62b084b3d41462c5546
S3wq2O/translation: 9268b9b2e31373e5ac2e29c19d08e1ef
BOqTi5/message: 11edf0427766c26db541d46379dc3c16
BOqTi5/placeholders/0/0: 9db68a4b386b9fa1fd1d5797c32be100
BOqTi5/placeholders/1/0: b837a9e8bd06b58be95699b3ee663f43
@@ -1915,9 +1939,9 @@ checksums:
Oi%2BJ%2BN/message: 313804fe615d51e0f0cbd599161d1ade
Oi%2BJ%2BN/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
GdgCoi/translation: c6a08c771f97d86b02cf10d8f5f2d788
GdgCoi/message: c6a08c771f97d86b02cf10d8f5f2d788
GdgCoi/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
GdgCoi/translation: c6a08c771f97d86b02cf10d8f5f2d788
Iqh0Uv/message: 0cc9dcd6ac624383e9977f66d4b3b777
Iqh0Uv/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
Iqh0Uv/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
@@ -2011,8 +2035,9 @@ checksums:
fuwKpE/origin/2/0: fc0c18a095a68277a0f90a115942683b
fuwKpE/origin/3/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/4/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/5/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/6/0: faa8a2576972b4eb31565d8ea6098f43
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
@@ -2043,7 +2068,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
@@ -2187,9 +2213,13 @@ checksums:
SkCNhl/message: 137017b2b3e885c4894ca8c899af5bc2
SkCNhl/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
SkCNhl/translation: 137017b2b3e885c4894ca8c899af5bc2
MpFIca/translation: 6e3f470ef5468c5195ae09f518b4dea4
66Jmqh/message: 5f56ef5447d5d3ae81d26936ca8e6758
66Jmqh/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
66Jmqh/translation: 5f56ef5447d5d3ae81d26936ca8e6758
MpFIca/message: 6e3f470ef5468c5195ae09f518b4dea4
MpFIca/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
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
@@ -2205,6 +2235,9 @@ checksums:
zYRVNp/message: 8b36455dd5b43e56d2ab06e522c3bacc
zYRVNp/origin/0/0: 97bd206a05414e2a571020d64e166093
zYRVNp/translation: 8b36455dd5b43e56d2ab06e522c3bacc
NM2hyD/message: dfd9e9abdca731ba9ca3fcff645b575e
NM2hyD/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
NM2hyD/translation: dfd9e9abdca731ba9ca3fcff645b575e
wgNoIs/message: eedc7cdb02de467c15dc418a066a77f2
wgNoIs/origin/0/0: 3c57a918258af4b44c187f58d203345f
wgNoIs/origin/1/0: 3c57a918258af4b44c187f58d203345f
@@ -2287,10 +2320,10 @@ checksums:
n1ekoW/message: ec7b8f314fe9bc6591006707484ede61
n1ekoW/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
n1ekoW/translation: ec7b8f314fe9bc6591006707484ede61
jbGGzf/translation: 7f18a6418cc7eb05158cff67247bc966
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
@@ -2351,11 +2384,11 @@ checksums:
veIDCY/message: 4be2811a8f3455618032ccb59e40658b
veIDCY/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
veIDCY/translation: 4be2811a8f3455618032ccb59e40658b
4%2BJaWo/translation: e346e4ed7d138dcc873db187922369da
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
@@ -2390,7 +2423,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
@@ -2447,6 +2481,10 @@ checksums:
NcFrgC/message: bd15a7b3d8b4bce18cd42682ea1ec164
NcFrgC/origin/0/0: 0ad2af981579a62b084b3d41462c5546
NcFrgC/translation: bd15a7b3d8b4bce18cd42682ea1ec164
ZWk38w/message: 0a6b07963240d329f206e280282e36da
ZWk38w/placeholders/0/0: 9622afa3aca4038b79417d808a505c87
ZWk38w/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
ZWk38w/translation: 0a6b07963240d329f206e280282e36da
C6gv54/message: 1573c77059bc92df17e661befda8d9b7
C6gv54/origin/0/0: 0ad2af981579a62b084b3d41462c5546
C6gv54/translation: 1573c77059bc92df17e661befda8d9b7
@@ -2519,6 +2557,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
@@ -2627,7 +2668,11 @@ checksums:
23nvb2/translation: b9dedce59b5594ee2a88bf5d55d6dfac
Oo4E6p/message: af985cf72c47df76e2f2139a427d9948
Oo4E6p/origin/0/0: 97bd206a05414e2a571020d64e166093
Oo4E6p/origin/1/0: 1b05beba6bdd908015fa8e798717f024
Oo4E6p/translation: af985cf72c47df76e2f2139a427d9948
d196%2F6/message: 64c37452b5ff985b44f1347ce0ef659f
d196%2F6/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
d196%2F6/translation: 64c37452b5ff985b44f1347ce0ef659f
K7k9u3/message: 1cf9d227042f6a65adba38cb7296830d
K7k9u3/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
K7k9u3/translation: 1cf9d227042f6a65adba38cb7296830d
@@ -2738,9 +2783,9 @@ checksums:
7Ufoyg/message: 5c418701b27b9acbc5dd199719007362
7Ufoyg/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
7Ufoyg/translation: 5c418701b27b9acbc5dd199719007362
uXKtPH/translation: 8a95d730b360db6041001450aa751ab4
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
@@ -2750,9 +2795,9 @@ checksums:
8AwlaR/message: acb1fdc14ed875815ae9f8a5254bc634
8AwlaR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
8AwlaR/translation: acb1fdc14ed875815ae9f8a5254bc634
i5yNAO/translation: 478092018a27a4e588cb5c70968ef11a
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
@@ -3014,6 +3059,9 @@ checksums:
vAj6xG/message: a6152a41b2d8f64d5a657e5b8bc808a9
vAj6xG/origin/0/0: 4980d8e27a628e7cd9a70b839d844083
vAj6xG/translation: a6152a41b2d8f64d5a657e5b8bc808a9
tUL16u/message: 9064ca54e8a3ad28c03a173bc7b01184
tUL16u/origin/0/0: f8e87f3fc3dc63bc7961601feb860fa8
tUL16u/translation: 9064ca54e8a3ad28c03a173bc7b01184
h2FKMV/message: 11d928b1993d95d54a95f85f8ae5016d
h2FKMV/origin/0/0: 6812b79a8fdc3527586b523d4c137340
h2FKMV/origin/1/0: 85d0a3237fa9329fba3f0278a2cd7284
@@ -3048,6 +3096,10 @@ checksums:
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

View File

@@ -10,6 +10,7 @@ import { t } from "@lingui/core/macro";
import Link from "@tiptap/extension-link";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import Typography from "@tiptap/extension-typography";
import {
BubbleMenu,
EditorContent,
@@ -17,7 +18,6 @@ import {
ReactRenderer,
useEditor,
} from "@tiptap/react";
import Typography from "@tiptap/extension-typography";
import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion";
import {
@@ -440,6 +440,7 @@ export default function Editor({
content,
onChange,
onBlur,
onSubmit,
readOnly = false,
workspaceMembers,
enableYouTubeEmbed = true,
@@ -449,6 +450,7 @@ export default function Editor({
content: string | null;
onChange?: (value: string) => void;
onBlur?: () => void;
onSubmit?: () => void;
readOnly?: boolean;
workspaceMembers: WorkspaceMember[];
enableYouTubeEmbed?: boolean;
@@ -457,6 +459,21 @@ 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);
const onSubmitRef = useRef(onSubmit);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
onBlurRef.current = onBlur;
}, [onBlur]);
useEffect(() => {
onSubmitRef.current = onSubmit;
}, [onSubmit]);
const editor = useEditor(
{
extensions: [
@@ -478,8 +495,8 @@ export default function Editor({
Placeholder.configure({
placeholder: readOnly
? ""
: placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`,
: (placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`),
}),
SlashCommands.configure({
commandItems: getCommandItems(disableHeadings),
@@ -497,24 +514,26 @@ export default function Editor({
suggestion: {
char: "@",
items: ({ query }: { query: string }) => {
const withEmail = workspaceMembers.filter((member) => member.email);
const withEmail = workspaceMembers.filter(
(member) => member.email,
);
const mapped = withEmail.map((member: WorkspaceMember) => ({
id: member.publicId,
label: member?.user?.name?.trim() || member.email || "",
image: member?.user?.image ?? null,
}));
const all: MentionItem[] = mapped.filter(
(item) => item.label && item.label.length > 0,
);
const q = query.toLowerCase().trim();
if (q === "") {
return all;
}
const filtered = all.filter((u) =>
u.label.toLowerCase().includes(q),
);
@@ -540,20 +559,20 @@ export default function Editor({
},
}),
Typography.configure({
openDoubleQuote: false,
closeDoubleQuote: false,
openSingleQuote: false,
closeSingleQuote: false,
oneHalf: false,
oneQuarter: false,
threeQuarters: false,
superscriptTwo: false,
superscriptThree: false,
openDoubleQuote: false,
closeDoubleQuote: false,
openSingleQuote: false,
closeSingleQuote: false,
oneHalf: false,
oneQuarter: false,
threeQuarters: false,
superscriptTwo: false,
superscriptThree: false,
}),
...(enableYouTubeEmbed ? [YouTubeNode] : []),
],
content,
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getHTML()),
onBlur: ({ event }) => {
if (
document
@@ -563,13 +582,20 @@ 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: {
attributes: {
class: "outline-none focus:outline-none focus-visible:ring-0",
},
handleKeyDown: (_view, event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
onSubmitRef.current?.();
return true;
}
return false;
},
},
editable: !readOnly,
injectCSS: false,
@@ -587,6 +613,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

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

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

@@ -4,12 +4,13 @@ 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 },
2: { plan: "pro", seats: 15, unlimitedSeats: false },
3: { plan: "pro", seats: null, unlimitedSeats: true },
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 {

View File

@@ -4,7 +4,6 @@ import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { tierConfig } from "./_utils";
@@ -110,11 +109,15 @@ export default withRateLimit(
const { db, user } = await createNextApiContext(req);
const cfg = tierConfig(license.tier);
const isActive = license.status === "active";
const status = isActive ? "active" : "inactive";
const status = license.status === "active" ? "active" : "inactive";
if (!user) {
await subscriptionRepo.upsertByPartnerLicenseKey(
// 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,
{
@@ -124,45 +127,18 @@ export default withRateLimit(
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
cfg.workspaceSlots,
);
}
if (!user) {
return res.redirect(
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
);
}
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
const workspace = memberships?.[0]?.workspace;
if (!workspace) {
await subscriptionRepo.upsertByPartnerLicenseKey(
db,
license.license_key,
{
plan: cfg.plan,
status,
partnerTier: license.tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
);
return res.redirect(
`/onboarding/workspace?license_key=${encodeURIComponent(license.license_key)}`,
);
}
await subscriptionRepo.upsertByPartnerLicenseKey(db, license.license_key, {
plan: cfg.plan,
status,
partnerTier: license.tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
referenceId: workspace.publicId,
});
if (isActive) {
await workspaceRepo.update(db, workspace.publicId, { plan: cfg.plan });
}
return res.redirect(`/?partner_activated=1`);
return res.redirect(
`/api/partner/link?license_key=${encodeURIComponent(license.license_key)}`,
);
}),
);

View File

@@ -27,38 +27,72 @@ export default withRateLimit(
);
}
const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key);
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license_key,
);
if (!sub) {
if (!allSlots.length) {
return res.redirect("/boards?partner_error=invalid_license");
}
if (sub.status !== "active") {
const activeSlots = allSlots.filter((s) =>
["active", "trialing"].includes(s.status),
);
if (!activeSlots.length) {
return res.redirect("/boards?partner_error=license_inactive");
}
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
const workspace = memberships?.[0]?.workspace;
const unlinkedSlot = activeSlots.find((s) => !s.referenceId);
if (!workspace) {
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.upsertByPartnerLicenseKey(db, license_key, {
plan: sub.plan,
status: sub.status,
partnerTier: sub.partnerTier ?? 1,
seats: sub.seats ?? null,
unlimitedSeats: sub.unlimitedSeats,
referenceId: workspace.publicId,
await subscriptionRepo.updateById(db, unlinkedSlot.id, {
referenceId: availableWorkspace.publicId,
});
await workspaceRepo.update(db, workspace.publicId, {
plan: sub.plan as "free" | "team" | "pro" | "enterprise",
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

@@ -2,17 +2,112 @@ 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) {
@@ -42,6 +137,12 @@ function verifySignature(
}
}
function hasReferenceId<T extends { referenceId: string | null | undefined }>(
s: T,
): s is T & { referenceId: string } {
return !!s.referenceId;
}
interface WebhookPayload {
event:
| "purchase"
@@ -93,107 +194,212 @@ export default withApiLogging(
const { db } = await createNextApiContext(req);
switch (event) {
case "purchase": {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: license_status,
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
break;
}
case "purchase":
case "activate": {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
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 sub = await subscriptionRepo.getByPartnerLicenseKey(
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license_key,
);
if (sub) {
const [, allSubs] = await Promise.all([
subscriptionRepo.updateById(db, sub.id, {
plan: "free",
status: "inactive",
}),
sub.referenceId
? subscriptionRepo.getByReferenceId(db, sub.referenceId)
: Promise.resolve([]),
]);
if (sub.referenceId) {
const hasActiveSub = getActiveSubscriptions(allSubs).some(
(s) => s.id !== sub.id,
await Promise.all(
allSlots.filter(hasReferenceId).map(async (slot) => {
const siblingSubs = await subscriptionRepo.getByReferenceId(
db,
slot.referenceId,
);
if (!hasActiveSub) {
await workspaceRepo.update(db, sub.referenceId, { plan: "free" });
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 sub = await subscriptionRepo.getByPartnerLicenseKey(
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
lookupKey,
);
if (sub) {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
referenceId: sub.referenceId ?? undefined,
});
if (prev_license_key) {
await subscriptionRepo.updateById(db, sub.id, {
status: "inactive",
});
}
if (sub.referenceId) {
await workspaceRepo.update(db, sub.referenceId, { plan: cfg.plan });
}
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) {
const sub = await subscriptionRepo.getByPartnerLicenseKey(
db,
prev_license_key,
);
if (sub) {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
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,
referenceId: sub.referenceId ?? undefined,
});
await subscriptionRepo.updateById(db, sub.id, {
status: "inactive",
});
}
}),
),
...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;
}

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

@@ -103,55 +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,
cardPrefix: selectedWorkspace.workspace.cardPrefix,
});
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,
cardPrefix: primaryWorkspace.cardPrefix,
});
}
}, [data, isLoading, isFetching, 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

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

@@ -1,6 +1,7 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import {
HiArrowRightOnRectangle,
HiEllipsisHorizontal,
HiLink,
HiOutlineDocumentDuplicate,
@@ -88,7 +89,7 @@ export default function BoardDropdown({
const isArchiveActionPending = updateBoard.isPending;
const items = [
...(isTemplate && canCreateBoard
...(!isTemplate && canCreateBoard
? [
{
label: t`Make template`,
@@ -119,6 +120,17 @@ export default function BoardDropdown({
},
]
: []),
...(!isTemplate && canEditBoard
? [
{
label: t`Move to workspace`,
action: () => openModal("MOVE_BOARD"),
icon: (
<HiArrowRightOnRectangle className="h-[16px] w-[16px] text-dark-900" />
),
},
]
: []),
{
label: isFavorite
? t`Remove from favorites`

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

@@ -0,0 +1,113 @@
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
import { useState } from "react";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function MoveBoardForm({
boardPublicId,
}: {
boardPublicId: string;
}) {
const router = useRouter();
const { closeModal } = useModal();
const { showPopup } = usePopup();
const { workspace, availableWorkspaces, switchWorkspace } = useWorkspace();
const [targetWorkspacePublicId, setTargetWorkspacePublicId] = useState("");
const otherWorkspaces = availableWorkspaces.filter(
(ws) => ws.publicId !== workspace.publicId && ws.role !== "guest",
);
const moveBoard = api.board.move.useMutation({
onSuccess: () => {
const targetWorkspace = availableWorkspaces.find(
(ws) => ws.publicId === targetWorkspacePublicId,
);
closeModal();
showPopup({
header: t`Board moved`,
message: t`The board has been moved to ${targetWorkspace?.name ?? "the workspace"}.`,
icon: "success",
});
if (targetWorkspace) {
switchWorkspace(targetWorkspace);
} else {
router.push("/boards");
}
},
onError: (error) => {
showPopup({
header: t`Unable to move board`,
message: error.message,
icon: "error",
});
},
});
const handleMoveBoard = () => {
if (!targetWorkspacePublicId) return;
moveBoard.mutate({
boardPublicId,
targetWorkspacePublicId,
});
};
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
{t`Move board to another workspace`}
</h2>
{otherWorkspaces.length === 0 ? (
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{t`You don't have any other workspaces to move this board to.`}
</p>
) : (
<>
<label
htmlFor="target-workspace"
className="mb-2 text-sm font-medium text-light-900 dark:text-dark-900"
>
{t`Destination workspace`}
</label>
<select
id="target-workspace"
value={targetWorkspacePublicId}
onChange={(e) => setTargetWorkspacePublicId(e.target.value)}
className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6"
>
<option value="">{t`Select a workspace`}</option>
{otherWorkspaces.map((ws) => (
<option key={ws.publicId} value={ws.publicId}>
{ws.name}
</option>
))}
</select>
<p className="mt-3 text-sm text-light-800 dark:text-dark-800">
{t`Card member assignments will be cleared when moving to a different workspace.`}
</p>
</>
)}
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button onClick={() => closeModal()} variant="secondary">
{t`Cancel`}
</Button>
{otherWorkspaces.length > 0 && (
<Button
onClick={handleMoveBoard}
isLoading={moveBoard.isPending}
disabled={!targetWorkspacePublicId}
>
{t`Move board`}
</Button>
)}
</div>
</div>
);
}

View File

@@ -46,6 +46,7 @@ import { CardContextMembersModal } from "./components/CardContextMembersModal";
import { CardContextMenu } from "./components/CardContextMenu";
import { CardContextMoveListModal } from "./components/CardContextMoveListModal";
import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
import { MoveBoardForm } from "./components/MoveBoardForm";
import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
import Filters from "./components/Filters";
import List from "./components/List";
@@ -460,6 +461,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "MOVE_BOARD"}
>
<MoveBoardForm boardPublicId={boardId ?? ""} />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CREATE_TEMPLATE"}
@@ -634,7 +642,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 +689,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}
@@ -782,7 +790,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

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

@@ -4,6 +4,7 @@ import {
HiHashtag,
HiLink,
HiOutlineCheckCircle,
HiOutlineDocumentDuplicate,
HiOutlineTrash,
} from "react-icons/hi2";
@@ -13,6 +14,7 @@ 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,
@@ -20,19 +22,44 @@ export default function CardDropdown({
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
@@ -99,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

@@ -2,9 +2,10 @@ import { t } from "@lingui/core/macro";
import { useForm } from "react-hook-form";
import { HiOutlineArrowUp } from "react-icons/hi2";
import Editor from "~/components/Editor";
import type { WorkspaceMember } from "~/components/Editor";
import Editor from "~/components/Editor";
import LoadingSpinner from "~/components/LoadingSpinner";
import { Tooltip } from "~/components/Tooltip";
import { usePermissions } from "~/hooks/usePermissions";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -51,6 +52,21 @@ const NewCommentForm = ({
});
};
const isMac =
typeof navigator !== "undefined" && navigator.userAgent.includes("Mac");
const submitTooltip = (
<div className="flex flex-row items-center gap-2 text-[11px]">
{t`Submit`}
<span className="inline-flex items-center justify-center rounded border border-light-400 bg-light-200 px-1.5 py-0.5 font-mono text-[8px] font-semibold text-neutral-900 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-950">
{isMac ? "⌘" : "Ctrl"}
</span>
<span className="inline-flex items-center justify-center rounded border border-light-400 bg-light-200 px-1.5 py-0.5 font-mono text-[8px] font-semibold text-neutral-900 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-950">
Enter
</span>
</div>
);
if (!canCreateComment) {
return null;
}
@@ -63,23 +79,26 @@ const NewCommentForm = ({
<Editor
content={watch("comment")}
onChange={(value) => setValue("comment", value)}
onSubmit={handleSubmit(onSubmit)}
workspaceMembers={workspaceMembers}
enableYouTubeEmbed={false}
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
disableHeadings={true}
/>
<div className="flex justify-end">
<button
type="submit"
disabled={addCommentMutation.isPending}
className="flex h-8 w-8 items-center justify-center rounded-full border border-light-600 bg-light-300 hover:bg-light-400 disabled:opacity-50 dark:border-dark-400 dark:bg-dark-200 dark:hover:bg-dark-400"
>
{addCommentMutation.isPending ? (
<LoadingSpinner size="sm" />
) : (
<HiOutlineArrowUp />
)}
</button>
<Tooltip content={submitTooltip} placement="top">
<button
type="submit"
disabled={addCommentMutation.isPending}
className="flex h-8 w-8 items-center justify-center rounded-full border border-light-600 bg-light-300 hover:bg-light-400 disabled:opacity-50 dark:border-dark-400 dark:bg-dark-200 dark:hover:bg-dark-400"
>
{addCommentMutation.isPending ? (
<LoadingSpinner size="sm" />
) : (
<HiOutlineArrowUp />
)}
</button>
</Tooltip>
</div>
</form>
);

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,14 +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>
</>
)}
{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
@@ -356,10 +361,13 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
boardPublicId={boardId}
cardCreatedBy={card?.createdBy}
ticketNumber={
card.cardNumber != null && card.list.board.workspace.cardPrefix
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

@@ -26,9 +26,13 @@ import { api } from "~/utils/api";
export function InviteMemberForm({
subscriptions,
unlimitedSeats,
memberCount,
seatLimit,
}: {
subscriptions: Subscription[] | undefined;
unlimitedSeats: boolean;
memberCount: number;
seatLimit: number | null;
}) {
const utils = api.useUtils();
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
@@ -93,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`,
@@ -285,21 +298,27 @@ export function InviteMemberForm({
)}
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!isPartnerTier &&
!unlimitedSeats && (
(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`}
</span>
<span className="text-light-900 dark:text-dark-900">
{memberCount} / {seatLimit} {t`seats`}
</span>
</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>
{!isPartnerTier && (
<p className="mt-1">
{unlimitedSeats
? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
</p>
)}
<p className="mt-1">
{t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
</p>
</div>
) : (
<div>
@@ -312,7 +331,7 @@ export function InviteMemberForm({
</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">

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";
@@ -84,6 +88,10 @@ export default function MembersPage() {
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 ??
@@ -402,6 +410,8 @@ export default function MembersPage() {
<InviteMemberForm
subscriptions={subscriptions}
unlimitedSeats={unlimitedSeats}
memberCount={memberCount}
seatLimit={seatLimit}
/>
</Modal>

View File

@@ -40,7 +40,8 @@ export default function WorkspaceNameView() {
const billing = searchParams.get("billing") ?? "annual";
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const licenseKeyParam = searchParams.get("license_key");
const isLicenseFlow = !!licenseKeyParam;
const isLicenseFlow =
!!licenseKeyParam || searchParams.get("partner") === "1";
const { showPopup } = usePopup();
useEffect(() => {
@@ -103,6 +104,7 @@ export default function WorkspaceNameView() {
if (!workspace.publicId) return;
localStorage.setItem("workspacePublicId", workspace.publicId);
void utils.workspace.all.invalidate();
void utils.workspace.hasAvailablePartnerSlot.invalidate();
const storedLicenseKey = localStorage.getItem("partnerLicenseKey");
if (storedLicenseKey) {
localStorage.removeItem("partnerLicenseKey");

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

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

@@ -35,6 +35,10 @@
"./utils/permissions": {
"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

@@ -0,0 +1,268 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
// Mock all imports used by board.ts before importing the router
vi.mock("@kan/db/repository/board.repo", () => ({
getBoardForMove: vi.fn(),
isBoardSlugAvailable: vi.fn(),
moveToWorkspace: vi.fn(),
getIdByPublicId: vi.fn(),
getByPublicId: vi.fn(),
getWithListIdsByPublicId: vi.fn(),
getWithLatestListIndexByPublicId: vi.fn(),
getWorkspaceAndBoardIdByBoardPublicId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
updatePositions: vi.fn(),
archive: vi.fn(),
deleteBoard: vi.fn(),
getAllByWorkspaceId: vi.fn(),
createFavorite: vi.fn(),
deleteFavorite: vi.fn(),
getFavorite: vi.fn(),
}));
vi.mock("@kan/db/repository/workspace.repo", () => ({
getByPublicId: vi.fn(),
}));
vi.mock("@kan/db/repository/card.repo", () => ({
getByPublicId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
}));
vi.mock("@kan/db/repository/cardActivity.repo", () => ({
create: vi.fn(),
}));
vi.mock("@kan/db/repository/label.repo", () => ({
create: vi.fn(),
getById: vi.fn(),
getByPublicId: vi.fn(),
}));
vi.mock("@kan/db/repository/list.repo", () => ({
create: vi.fn(),
getByPublicId: vi.fn(),
}));
vi.mock("../utils/permissions", () => ({
assertCanEdit: vi.fn(),
assertCanDelete: vi.fn(),
assertPermission: vi.fn(),
}));
vi.mock("@kan/shared/utils", () => ({
generateSlug: vi.fn((name: string) => name.toLowerCase().replace(/\s+/g, "-")),
generateUID: vi.fn(() => "abc123"),
generateAvatarUrl: vi.fn(),
convertDueDateFiltersToRanges: vi.fn(),
}));
vi.mock("@kan/shared/constants", () => ({
colours: [],
}));
import * as boardRepo from "@kan/db/repository/board.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { assertCanEdit, assertPermission } from "../utils/permissions";
const mockGetBoardForMove = boardRepo.getBoardForMove as ReturnType<typeof vi.fn>;
const mockIsBoardSlugAvailable = boardRepo.isBoardSlugAvailable as ReturnType<typeof vi.fn>;
const mockMoveToWorkspace = boardRepo.moveToWorkspace as ReturnType<typeof vi.fn>;
const mockWorkspaceGetByPublicId = workspaceRepo.getByPublicId as ReturnType<typeof vi.fn>;
const mockAssertCanEdit = assertCanEdit as ReturnType<typeof vi.fn>;
const mockAssertPermission = assertPermission as ReturnType<typeof vi.fn>;
describe("board.move", () => {
const mockDb = {} as never;
const mockUser = { id: "user-123", name: "Test User", email: "test@example.com" };
const mockInput = {
boardPublicId: "brd-123456789",
targetWorkspacePublicId: "ws-target-789",
};
const mockBoard = {
id: 1,
name: "My Board",
slug: "my-board",
type: "board" as const,
isArchived: false,
workspaceId: 10,
createdBy: "user-123",
};
const mockTargetWorkspace = { id: 20, publicId: "ws-target-789" };
beforeEach(() => {
vi.clearAllMocks();
mockAssertCanEdit.mockResolvedValue(undefined);
mockAssertPermission.mockResolvedValue(undefined);
});
it("throws UNAUTHORIZED when user is not authenticated", async () => {
const { boardRouter } = await import("./board");
const ctx = { user: null, db: mockDb } as never;
await expect(
boardRouter.createCaller(ctx).move(mockInput),
).rejects.toThrow(TRPCError);
});
it("throws NOT_FOUND when board does not exist", async () => {
const { boardRouter } = await import("./board");
mockGetBoardForMove.mockResolvedValueOnce(null);
const ctx = { user: mockUser, db: mockDb } as never;
await expect(
boardRouter.createCaller(ctx).move(mockInput),
).rejects.toThrow(TRPCError);
});
it("throws BAD_REQUEST for template boards", async () => {
const { boardRouter } = await import("./board");
mockGetBoardForMove.mockResolvedValueOnce({ ...mockBoard, type: "template" });
const ctx = { user: mockUser, db: mockDb } as never;
await expect(
boardRouter.createCaller(ctx).move(mockInput),
).rejects.toThrow(TRPCError);
});
it("throws BAD_REQUEST for archived boards", async () => {
const { boardRouter } = await import("./board");
mockGetBoardForMove.mockResolvedValueOnce({ ...mockBoard, isArchived: true });
const ctx = { user: mockUser, db: mockDb } as never;
await expect(
boardRouter.createCaller(ctx).move(mockInput),
).rejects.toThrow(TRPCError);
});
it("checks board:edit permission on source workspace", async () => {
const { boardRouter } = await import("./board");
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
mockAssertCanEdit.mockRejectedValueOnce(
new TRPCError({ code: "FORBIDDEN", message: "No permission" }),
);
const ctx = { user: mockUser, db: mockDb } as never;
await expect(
boardRouter.createCaller(ctx).move(mockInput),
).rejects.toThrow(TRPCError);
expect(mockAssertCanEdit).toHaveBeenCalledWith(
mockDb,
mockUser.id,
mockBoard.workspaceId,
"board:edit",
mockBoard.createdBy,
);
});
it("throws NOT_FOUND when target workspace does not exist", async () => {
const { boardRouter } = await import("./board");
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
mockWorkspaceGetByPublicId.mockResolvedValueOnce(null);
const ctx = { user: mockUser, db: mockDb } as never;
await expect(
boardRouter.createCaller(ctx).move(mockInput),
).rejects.toThrow(TRPCError);
});
it("throws NOT_FOUND when target workspace is soft-deleted", async () => {
const { boardRouter } = await import("./board");
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
mockWorkspaceGetByPublicId.mockResolvedValueOnce({
...mockTargetWorkspace,
deletedAt: new Date(),
});
const ctx = { user: mockUser, db: mockDb } as never;
await expect(
boardRouter.createCaller(ctx).move(mockInput),
).rejects.toThrow(TRPCError);
});
it("throws BAD_REQUEST when target is the same workspace", async () => {
const { boardRouter } = await import("./board");
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
mockWorkspaceGetByPublicId.mockResolvedValueOnce({
id: mockBoard.workspaceId,
publicId: "ws-target-789",
});
const ctx = { user: mockUser, db: mockDb } as never;
await expect(
boardRouter.createCaller(ctx).move(mockInput),
).rejects.toThrow(TRPCError);
});
it("checks board:create permission on target workspace", async () => {
const { boardRouter } = await import("./board");
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockTargetWorkspace);
mockAssertPermission.mockRejectedValueOnce(
new TRPCError({ code: "FORBIDDEN", message: "No permission" }),
);
const ctx = { user: mockUser, db: mockDb } as never;
await expect(
boardRouter.createCaller(ctx).move(mockInput),
).rejects.toThrow(TRPCError);
expect(mockAssertPermission).toHaveBeenCalledWith(
mockDb,
mockUser.id,
mockTargetWorkspace.id,
"board:create",
);
});
it("appends UID suffix when slug conflicts in target workspace", async () => {
const { boardRouter } = await import("./board");
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockTargetWorkspace);
mockIsBoardSlugAvailable.mockResolvedValueOnce(false);
mockMoveToWorkspace.mockResolvedValueOnce(undefined);
const ctx = { user: mockUser, db: mockDb } as never;
await boardRouter.createCaller(ctx).move(mockInput);
expect(mockMoveToWorkspace).toHaveBeenCalledWith(
mockDb,
mockBoard.id,
mockTargetWorkspace.id,
"my-board-abc123",
);
});
it("moves board successfully with available slug", async () => {
const { boardRouter } = await import("./board");
mockGetBoardForMove.mockResolvedValueOnce(mockBoard);
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockTargetWorkspace);
mockIsBoardSlugAvailable.mockResolvedValueOnce(true);
mockMoveToWorkspace.mockResolvedValueOnce(undefined);
const ctx = { user: mockUser, db: mockDb } as never;
const result = await boardRouter.createCaller(ctx).move(mockInput);
expect(result).toEqual({ success: true });
expect(mockMoveToWorkspace).toHaveBeenCalledWith(
mockDb,
mockBoard.id,
mockTargetWorkspace.id,
"my-board",
);
});
});

View File

@@ -644,6 +644,119 @@ export const boardRouter = createTRPCRouter({
}
}
return { success: true };
}),
move: protectedProcedure
.meta({
openapi: {
method: "POST",
path: "/boards/{boardPublicId}/move",
summary: "Move board to another workspace",
description:
"Moves a board and all its contents to a different workspace",
tags: ["Boards"],
protect: true,
},
})
.input(
z.object({
boardPublicId: z.string().min(12),
targetWorkspacePublicId: z.string().min(12),
}),
)
.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",
});
// Get source board
const board = await boardRepo.getBoardForMove(
ctx.db,
input.boardPublicId,
);
if (!board)
throw new TRPCError({
message: `Board with public ID ${input.boardPublicId} not found`,
code: "NOT_FOUND",
});
if (board.type === "template")
throw new TRPCError({
message: `Templates cannot be moved between workspaces`,
code: "BAD_REQUEST",
});
if (board.isArchived)
throw new TRPCError({
message: `Archived boards cannot be moved. Unarchive the board first.`,
code: "BAD_REQUEST",
});
// Check permission to edit board in source workspace
await assertCanEdit(
ctx.db,
userId,
board.workspaceId,
"board:edit",
board.createdBy ?? null,
);
// Get target workspace. workspaceRepo.getByPublicId does not yet
// filter soft-deleted workspaces (legacy: same is true for several
// peer callers); guard at this call site so we never move a board
// into a tombstoned workspace. A wider fix to make the repo treat
// deleted-as-not-found is a separate concern.
const targetWorkspace = await workspaceRepo.getByPublicId(
ctx.db,
input.targetWorkspacePublicId,
);
if (!targetWorkspace || targetWorkspace.deletedAt)
throw new TRPCError({
message: `Target workspace not found`,
code: "NOT_FOUND",
});
if (targetWorkspace.id === board.workspaceId)
throw new TRPCError({
message: `Board is already in this workspace`,
code: "BAD_REQUEST",
});
// Check permission to create boards in target workspace
await assertPermission(
ctx.db,
userId,
targetWorkspace.id,
"board:create",
);
let slug = board.slug ?? generateSlug(board.name);
const isSlugAvailable = await boardRepo.isBoardSlugAvailable(
ctx.db,
slug,
targetWorkspace.id,
);
if (!isSlugAvailable) {
slug = `${slug}-${generateUID()}`;
}
// Move the board
await boardRepo.moveToWorkspace(
ctx.db,
board.id,
targetWorkspace.id,
slug,
);
return { success: true };
}),
checkSlugAvailability: publicProcedure

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,
@@ -246,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,
@@ -324,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",
@@ -412,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",
@@ -901,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({
@@ -1048,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) {
@@ -1291,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,
@@ -1347,7 +1358,7 @@ export const cardRouter = createTRPCRouter({
checklistId: newChecklist.id,
title: item.title,
createdBy: userId,
completed: item.completed ?? false,
completed: false,
});
}
}

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,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,47 @@ 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!,
};
}),
@@ -412,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 };
}),
@@ -560,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

@@ -47,6 +47,7 @@ export const cardDetailSchema = z.object({
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),

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);
@@ -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 @@
DROP INDEX IF EXISTS "subscription_partner_license_key_idx";

File diff suppressed because it is too large Load Diff

View File

@@ -239,6 +239,13 @@
"when": 1778617946519,
"tag": "20260512203226_AddPartnerLicenseToSubscription",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1780057951781,
"tag": "20260529123231_DropPartnerLicenseKeyUniqueConstraint",
"breakpoints": true
}
]
}

View File

@@ -724,6 +724,30 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
return result;
};
/**
* Fetches the board fields needed by the move mutation:
* identity, naming, type guards, and workspace ownership.
* Soft-deleted boards are excluded — moving a tombstoned board has
* no defensible semantics.
*/
export const getBoardForMove = async (
db: dbClient,
boardPublicId: string,
) => {
return db.query.boards.findFirst({
columns: {
id: true,
name: true,
slug: true,
type: true,
isArchived: true,
workspaceId: true,
createdBy: true,
},
where: and(eq(boards.publicId, boardPublicId), isNull(boards.deletedAt)),
});
};
export const isBoardSlugAvailable = async (
db: dbClient,
boardSlug: string,
@@ -969,6 +993,61 @@ export const createFromSnapshot = async (
});
};
export const moveToWorkspace = async (
db: dbClient,
boardId: number,
targetWorkspaceId: number,
newSlug?: string,
) => {
return db.transaction(async (tx) => {
// Update the board's workspace (and slug if provided)
const [updatedBoard] = await tx
.update(boards)
.set({
workspaceId: targetWorkspaceId,
...(newSlug && { slug: newSlug }),
updatedAt: new Date(),
})
.where(eq(boards.id, boardId))
.returning({
publicId: boards.publicId,
name: boards.name,
});
if (!updatedBoard) throw new Error("Failed to move board");
// Get every card ID ever belonging to this board, including
// soft-deleted cards under soft-deleted lists. Member assignments
// point at workspace-scoped members that no longer exist after
// the move; if we leave assignments on soft-deleted cards, a later
// restore would resurrect rogue references to the old workspace.
const boardLists = await tx
.select({ id: lists.id })
.from(lists)
.where(eq(lists.boardId, boardId));
if (boardLists.length > 0) {
const listIds = boardLists.map((l) => l.id);
const boardCards = await tx
.select({ id: cards.id })
.from(cards)
.where(inArray(cards.listId, listIds));
if (boardCards.length > 0) {
const cardIds = boardCards.map((c) => c.id);
// Clear all card member assignments (they reference workspace-scoped members)
await tx
.delete(cardToWorkspaceMembers)
.where(inArray(cardToWorkspaceMembers.cardId, cardIds));
}
}
return updatedBoard;
});
};
export const addUserFavorite = async (
db: dbClient,
userId: string,

View File

@@ -107,7 +107,12 @@ export const create = async (
cardNumber,
dueDate: cardInput.dueDate ?? null,
})
.returning({ id: cards.id, listId: cards.listId, publicId: cards.publicId, cardNumber: cards.cardNumber });
.returning({
id: cards.id,
listId: cards.listId,
publicId: cards.publicId,
cardNumber: cards.cardNumber,
});
if (!result[0]) throw new Error("Unable to create card");
@@ -321,8 +326,7 @@ export const bulkCreate = async (
.where(eq(workspaces.id, workspaceId))
.returning({ cardCounter: workspaces.cardCounter });
if (!counterResult)
throw new Error(`Workspace ${workspaceId} not found`);
if (!counterResult) throw new Error(`Workspace ${workspaceId} not found`);
const last = counterResult.cardCounter;
const start = last - count + 1;
@@ -486,6 +490,7 @@ export const getWithListAndMembersByPublicId = async (
dueDate: true,
createdBy: true,
cardNumber: true,
index: true,
},
with: {
labels: {

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),
});

View File

@@ -1,4 +1,4 @@
import { and, count, eq, isNull } from "drizzle-orm";
import { and, count, eq, isNull, ne, or } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import type { MemberRole, MemberStatus } from "@kan/db/schema";
@@ -19,6 +19,27 @@ export const getActiveCount = async (db: dbClient) => {
return result[0]?.count ?? 0;
};
export const getCountByWorkspaceId = async (
db: dbClient,
workspaceId: number,
) => {
const result = await db
.select({ count: count() })
.from(workspaceMembers)
.where(
and(
eq(workspaceMembers.workspaceId, workspaceId),
isNull(workspaceMembers.deletedAt),
or(
eq(workspaceMembers.status, "active"),
eq(workspaceMembers.status, "invited"),
),
),
);
return result[0]?.count ?? 0;
};
export const create = async (
db: dbClient,
memberInput: {
@@ -71,14 +92,14 @@ export const getByPublicIdsWithUsers = async (
return db.query.workspaceMembers.findMany({
where: (members, { inArray: inArrayFn, eq, and, isNull: isNullFn }) => {
const conditions = [inArrayFn(members.publicId, memberPublicIds)];
if (workspaceId) {
conditions.push(eq(members.workspaceId, workspaceId));
}
conditions.push(eq(members.status, "active"));
conditions.push(isNullFn(members.deletedAt));
return and(...conditions);
},
with: {
@@ -172,6 +193,68 @@ export const pauseAllMembers = async (db: dbClient, workspaceId: number) => {
);
};
export const getPreservableMemberId = async (
db: dbClient,
workspaceId: number,
ownerUserId: string | null,
): Promise<string | null> => {
if (ownerUserId) {
const owner = await db.query.workspaceMembers.findFirst({
columns: { userId: true },
where: and(
eq(workspaceMembers.workspaceId, workspaceId),
eq(workspaceMembers.userId, ownerUserId),
eq(workspaceMembers.status, "active"),
isNull(workspaceMembers.deletedAt),
),
});
if (owner?.userId) return owner.userId;
}
const admin = await db.query.workspaceMembers.findFirst({
columns: { userId: true },
where: and(
eq(workspaceMembers.workspaceId, workspaceId),
eq(workspaceMembers.role, "admin"),
eq(workspaceMembers.status, "active"),
isNull(workspaceMembers.deletedAt),
),
orderBy: (m, { asc }) => [asc(m.createdAt)],
});
if (admin?.userId) return admin.userId;
const anyMember = await db.query.workspaceMembers.findFirst({
columns: { userId: true },
where: and(
eq(workspaceMembers.workspaceId, workspaceId),
eq(workspaceMembers.status, "active"),
isNull(workspaceMembers.deletedAt),
),
orderBy: (m, { asc }) => [asc(m.createdAt)],
});
return anyMember?.userId ?? null;
};
export const pauseMembersExcept = async (
db: dbClient,
workspaceId: number,
preserveUserId: string,
) => {
await db
.update(workspaceMembers)
.set({ status: "paused" })
.where(
and(
eq(workspaceMembers.workspaceId, workspaceId),
eq(workspaceMembers.status, "active"),
or(
isNull(workspaceMembers.userId),
ne(workspaceMembers.userId, preserveUserId),
),
),
);
};
export const updateRole = async (
db: dbClient,
args: {

View File

@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { and, asc, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { subscription } from "@kan/db/schema";
@@ -15,6 +15,9 @@ export const updateById = async (
periodEnd?: Date | null;
cancelAtPeriodEnd?: boolean | null;
stripeSubscriptionId?: string | null;
referenceId?: string | null;
partnerLicenseKey?: string;
partnerTier?: number;
},
) => {
const [result] = await db
@@ -29,6 +32,7 @@ export const updateById = async (
plan: subscription.plan,
status: subscription.status,
unlimitedSeats: subscription.unlimitedSeats,
referenceId: subscription.referenceId,
});
return result;
@@ -63,6 +67,24 @@ export const updateByStripeSubscriptionId = async (
return result;
};
export const updateAllByPartnerLicenseKey = async (
db: dbClient,
partnerLicenseKey: string,
updates: {
plan?: string;
status?: string;
partnerTier?: number;
seats?: number | null;
unlimitedSeats?: boolean;
},
) => {
return await db
.update(subscription)
.set({ ...updates, updatedAt: new Date() })
.where(eq(subscription.partnerLicenseKey, partnerLicenseKey))
.returning({ id: subscription.id });
};
export const getByStripeSubscriptionId = async (
db: dbClient,
stripeSubscriptionId: string,
@@ -96,13 +118,63 @@ export const getByPartnerLicenseKey = async (
db: dbClient,
partnerLicenseKey: string,
) => {
const result = await db.query.subscription.findFirst({
return await db.query.subscription.findFirst({
where: eq(subscription.partnerLicenseKey, partnerLicenseKey),
});
return result;
};
export const upsertByPartnerLicenseKey = async (
export const getAllByPartnerLicenseKey = async (
db: dbClient,
partnerLicenseKey: string,
) => {
return await db.query.subscription.findMany({
where: eq(subscription.partnerLicenseKey, partnerLicenseKey),
orderBy: [asc(subscription.id)],
});
};
export const getFirstUnlinkedSlotByLicenseKey = async (
db: dbClient,
partnerLicenseKey: string,
) => {
return await db.query.subscription.findFirst({
where: and(
eq(subscription.partnerLicenseKey, partnerLicenseKey),
isNull(subscription.referenceId),
inArray(subscription.status, ["active", "trialing"]),
),
orderBy: [asc(subscription.id)],
});
};
export const getAllActivePartnerSubsByWorkspaceIds = async (
db: dbClient,
workspacePublicIds: string[],
) => {
if (workspacePublicIds.length === 0) return [];
return await db.query.subscription.findMany({
where: and(
inArray(subscription.referenceId, workspacePublicIds),
isNotNull(subscription.partnerLicenseKey),
inArray(subscription.status, ["active", "trialing"]),
),
});
};
export const getFirstActivePartnerSubByWorkspaceIds = async (
db: dbClient,
workspacePublicIds: string[],
) => {
return await db.query.subscription.findFirst({
where: and(
inArray(subscription.referenceId, workspacePublicIds),
isNotNull(subscription.partnerLicenseKey),
inArray(subscription.status, ["active", "trialing"]),
),
});
};
export const createPartnerLicenseSlots = async (
db: dbClient,
partnerLicenseKey: string,
data: {
@@ -111,20 +183,13 @@ export const upsertByPartnerLicenseKey = async (
partnerTier: number;
seats: number | null;
unlimitedSeats: boolean;
referenceId?: string;
},
count: number,
) => {
const [result] = await db
.insert(subscription)
.values({
partnerLicenseKey,
...data,
referenceId: data.referenceId ?? null,
})
.onConflictDoUpdate({
target: subscription.partnerLicenseKey,
set: { ...data, updatedAt: new Date() },
})
.returning();
return result;
const rows = Array.from({ length: count }, () => ({
partnerLicenseKey,
...data,
referenceId: null,
}));
return await db.insert(subscription).values(rows).returning();
};

View File

@@ -176,6 +176,8 @@ export const getByPublicId = (db: dbClient, workspacePublicId: string) => {
name: true,
plan: true,
slug: true,
deletedAt: true,
createdBy: true,
},
where: eq(workspaces.publicId, workspacePublicId),
});
@@ -314,6 +316,16 @@ export const getAllByUserId = async (db: dbClient, userId: string) => {
return result.filter((member) => !member.workspace.deletedAt);
};
export const getAllOwnedByUserId = async (db: dbClient, userId: string) => {
return await db.query.workspaces.findMany({
columns: {
publicId: true,
plan: true,
},
where: and(eq(workspaces.createdBy, userId), isNull(workspaces.deletedAt)),
});
};
export const getMemberByPublicId = (db: dbClient, memberPublicId: string) => {
return db.query.workspaceMembers.findFirst({
columns: {

View File

@@ -5,42 +5,33 @@ import {
integer,
pgTable,
timestamp,
uniqueIndex,
varchar,
} from "drizzle-orm/pg-core";
import { workspaces } from "./workspaces";
export const subscription = pgTable(
"subscription",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
plan: varchar("plan", { length: 255 }).notNull(),
referenceId: varchar("referenceId", { length: 12 }).references(
() => workspaces.publicId,
{ onDelete: "set null" },
),
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
status: varchar("status", { length: 255 }).notNull(),
periodStart: timestamp("periodStart"),
periodEnd: timestamp("periodEnd"),
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
seats: integer("seats"),
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
trialStart: timestamp("trialStart"),
trialEnd: timestamp("trialEnd"),
partnerLicenseKey: varchar("partnerLicenseKey", { length: 255 }),
partnerTier: integer("partnerTier"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
},
(table) => [
uniqueIndex("subscription_partner_license_key_idx").on(
table.partnerLicenseKey,
),
],
).enableRLS();
export const subscription = pgTable("subscription", {
id: bigserial("id", { mode: "number" }).primaryKey(),
plan: varchar("plan", { length: 255 }).notNull(),
referenceId: varchar("referenceId", { length: 12 }).references(
() => workspaces.publicId,
{ onDelete: "set null" },
),
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
status: varchar("status", { length: 255 }).notNull(),
periodStart: timestamp("periodStart"),
periodEnd: timestamp("periodEnd"),
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
seats: integer("seats"),
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
trialStart: timestamp("trialStart"),
trialEnd: timestamp("trialEnd"),
partnerLicenseKey: varchar("partnerLicenseKey", { length: 255 }),
partnerTier: integer("partnerTier"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
}).enableRLS();
export const subscriptionsRelations = relations(subscription, ({ one }) => ({
workspace: one(workspaces, {

29
packages/mcp/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@kan/mcp",
"version": "0.1.0",
"description": "MCP server for Kan — control workspaces, boards, lists, and cards via AI",
"type": "module",
"bin": {
"kan-mcp": "./dist/index.js"
},
"files": [
"dist",
"README.md"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc --noEmit false --declaration false --emitDeclarationOnly false --outDir dist",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"zod": "catalog:"
},
"devDependencies": {
"@kan/tsconfig": "workspace:*",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,64 @@
export interface KanConfig {
baseUrl: string;
apiToken: string;
}
function getConfig(): KanConfig {
const baseUrl = process.env["KAN_BASE_URL"];
const apiToken = process.env["KAN_API_TOKEN"];
if (!baseUrl) {
throw new Error("KAN_BASE_URL environment variable is required");
}
if (!apiToken) {
throw new Error("KAN_API_TOKEN environment variable is required");
}
return {
baseUrl: baseUrl.replace(/\/$/, ""),
apiToken,
};
}
export class KanApiError extends Error {
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly body: unknown,
) {
super(`Kan API error ${status} ${statusText}: ${JSON.stringify(body)}`);
this.name = "KanApiError";
}
}
export async function kanRequest<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const config = getConfig();
const url = `${config.baseUrl}/api/v1${path}`;
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiToken}`,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
let data: unknown;
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
data = await res.json();
} else {
data = await res.text();
}
if (!res.ok) {
throw new KanApiError(res.status, res.statusText, data);
}
return data as T;
}

27
packages/mcp/src/index.ts Normal file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerWorkspaceTools } from "./tools/workspace.js";
import { registerBoardTools } from "./tools/board.js";
import { registerListTools } from "./tools/list.js";
import { registerCardTools } from "./tools/card.js";
import { registerChecklistTools } from "./tools/checklist.js";
import { registerLabelTools } from "./tools/label.js";
import { registerMemberTools } from "./tools/member.js";
const server = new McpServer({
name: "kan",
version: "0.1.0",
});
registerWorkspaceTools(server);
registerBoardTools(server);
registerListTools(server);
registerCardTools(server);
registerChecklistTools(server);
registerLabelTools(server);
registerMemberTools(server);
const transport = new StdioServerTransport();
await server.connect(transport);

View File

@@ -0,0 +1,144 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { kanRequest } from "../client.js";
export function registerBoardTools(server: McpServer): void {
server.tool(
"list_boards",
"List all boards in a workspace. Requires the workspace publicId — use find_workspace_by_name first if you only know the workspace name.",
{ workspacePublicId: z.string().min(12).describe("The workspace's 12-character public ID (not the name). Get it from list_workspaces or find_workspace_by_name first.") },
async ({ workspacePublicId }) => {
const data = await kanRequest("GET", `/workspaces/${workspacePublicId}/boards`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"find_board_by_name",
"Find a board by workspace name and board name (both case-insensitive). Resolves workspace name → publicId, then board name → publicId automatically. Use this when you only know names.",
{
workspaceName: z.string().describe("The workspace name (e.g. 'UC Roleplay')"),
boardName: z.string().describe("The board name (e.g. 'Mechanics Rework')"),
},
async ({ workspaceName, boardName }) => {
const workspaces = await kanRequest<{ publicId: string; name: string }[]>("GET", "/workspaces");
const workspace = workspaces.find(
(w) => w.name.toLowerCase() === workspaceName.toLowerCase(),
);
if (!workspace) {
const names = workspaces.map((w) => w.name).join(", ");
return {
content: [
{
type: "text",
text: `No workspace found with name "${workspaceName}". Available: ${names}`,
},
],
};
}
const boards = await kanRequest<{ publicId: string; name: string }[]>(
"GET",
`/workspaces/${workspace.publicId}/boards`,
);
const board = boards.find(
(b) => b.name.toLowerCase() === boardName.toLowerCase(),
);
if (!board) {
const names = boards.map((b) => b.name).join(", ");
return {
content: [
{
type: "text",
text: `No board found with name "${boardName}" in workspace "${workspaceName}". Available boards: ${names}`,
},
],
};
}
return { content: [{ type: "text", text: JSON.stringify(board, null, 2) }] };
},
);
server.tool(
"get_board",
"Get a board by its public ID, including its lists and cards",
{
boardPublicId: z.string().describe("The board's public ID"),
labelPublicId: z.string().optional().describe("Filter cards by label public ID"),
memberPublicId: z.string().optional().describe("Filter cards by member public ID"),
},
async ({ boardPublicId, labelPublicId, memberPublicId }) => {
const params = new URLSearchParams();
if (labelPublicId) params.set("labelPublicId", labelPublicId);
if (memberPublicId) params.set("memberPublicId", memberPublicId);
const qs = params.toString() ? `?${params}` : "";
const data = await kanRequest("GET", `/boards/${boardPublicId}${qs}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"get_board_by_slug",
"Get a board by workspace slug and board slug",
{
workspaceSlug: z.string().describe("The workspace slug"),
boardSlug: z.string().describe("The board slug"),
},
async ({ workspaceSlug, boardSlug }) => {
const data = await kanRequest("GET", `/workspaces/${workspaceSlug}/boards/${boardSlug}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"create_board",
"Create a new board in a workspace",
{
workspacePublicId: z.string().describe("The workspace's public ID"),
name: z.string().describe("Board name"),
slug: z.string().optional().describe("URL-friendly slug (auto-generated if omitted)"),
visibility: z
.enum(["public", "private"])
.optional()
.describe("Board visibility (default: private)"),
},
async ({ workspacePublicId, name, slug, visibility }) => {
const data = await kanRequest("POST", `/workspaces/${workspacePublicId}/boards`, {
name,
slug,
visibility,
});
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"update_board",
"Update a board's name, slug, visibility, or favorite status",
{
boardPublicId: z.string().describe("The board's public ID"),
name: z.string().optional().describe("New board name"),
slug: z.string().optional().describe("New board slug"),
visibility: z.enum(["public", "private"]).optional().describe("New visibility"),
isFavorite: z.boolean().optional().describe("Whether the board is favorited"),
},
async ({ boardPublicId, name, slug, visibility, isFavorite }) => {
const data = await kanRequest("PUT", `/boards/${boardPublicId}`, {
name,
slug,
visibility,
isFavorite,
});
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"delete_board",
"Delete a board (soft delete)",
{ boardPublicId: z.string().describe("The board's public ID") },
async ({ boardPublicId }) => {
const data = await kanRequest("DELETE", `/boards/${boardPublicId}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
}

View File

@@ -0,0 +1,192 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { kanRequest } from "../client.js";
export function registerCardTools(server: McpServer): void {
server.tool(
"create_card",
"Create a new card in a list",
{
listPublicId: z.string().describe("The list's public ID"),
title: z.string().describe("Card title"),
description: z.string().optional().describe("Card description (markdown supported)"),
dueDate: z.string().optional().describe("Due date in ISO 8601 format"),
labelPublicIds: z
.array(z.string())
.optional()
.describe("Public IDs of labels to attach"),
memberPublicIds: z
.array(z.string())
.optional()
.describe("Public IDs of workspace members to assign"),
position: z
.enum(["start", "end"])
.optional()
.describe("Where to insert the card in the list (default: end)"),
},
async ({ listPublicId, title, description, dueDate, labelPublicIds, memberPublicIds, position }) => {
const data = await kanRequest("POST", "/cards", {
listPublicId,
title,
description,
dueDate,
labelPublicIds: labelPublicIds ?? [],
memberPublicIds: memberPublicIds ?? [],
position: position ?? "end",
});
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"get_card",
"Get full details of a card including comments, checklists, labels and members",
{ cardPublicId: z.string().describe("The card's public ID") },
async ({ cardPublicId }) => {
const data = await kanRequest("GET", `/cards/${cardPublicId}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"update_card",
"Update a card's title, description, due date, or move it to another list",
{
cardPublicId: z.string().describe("The card's public ID"),
title: z.string().optional().describe("New card title"),
description: z.string().optional().describe("New description"),
dueDate: z.string().nullable().optional().describe("Due date in ISO 8601, or null to clear"),
listPublicId: z.string().optional().describe("Move card to this list (public ID)"),
},
async ({ cardPublicId, title, description, dueDate, listPublicId }) => {
const data = await kanRequest("PUT", `/cards/${cardPublicId}`, {
title,
description,
dueDate,
listPublicId,
});
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"delete_card",
"Delete a card (soft delete)",
{ cardPublicId: z.string().describe("The card's public ID") },
async ({ cardPublicId }) => {
const data = await kanRequest("DELETE", `/cards/${cardPublicId}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"duplicate_card",
"Duplicate a card to the same or a different list",
{
cardPublicId: z.string().describe("The card's public ID to duplicate"),
targetListPublicId: z
.string()
.optional()
.describe("Target list public ID (defaults to same list)"),
},
async ({ cardPublicId, targetListPublicId }) => {
const data = await kanRequest("POST", `/cards/${cardPublicId}/duplicate`, {
targetListPublicId,
});
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"get_card_activities",
"Get the activity history of a card",
{
cardPublicId: z.string().describe("The card's public ID"),
cursor: z.string().optional().describe("Pagination cursor from a previous response"),
},
async ({ cardPublicId, cursor }) => {
const params = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
const data = await kanRequest("GET", `/cards/${cardPublicId}/activities${params}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"add_card_comment",
"Add a comment to a card",
{
cardPublicId: z.string().describe("The card's public ID"),
content: z.string().describe("Comment text"),
},
async ({ cardPublicId, content }) => {
const data = await kanRequest("POST", `/cards/${cardPublicId}/comments`, { content });
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"update_card_comment",
"Update the text of an existing comment",
{
cardPublicId: z.string().describe("The card's public ID"),
commentPublicId: z.string().describe("The comment's public ID"),
content: z.string().describe("New comment text"),
},
async ({ cardPublicId, commentPublicId, content }) => {
const data = await kanRequest(
"PUT",
`/cards/${cardPublicId}/comments/${commentPublicId}`,
{ content },
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"delete_card_comment",
"Delete a comment from a card",
{
cardPublicId: z.string().describe("The card's public ID"),
commentPublicId: z.string().describe("The comment's public ID"),
},
async ({ cardPublicId, commentPublicId }) => {
const data = await kanRequest(
"DELETE",
`/cards/${cardPublicId}/comments/${commentPublicId}`,
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"toggle_card_label",
"Add or remove a label on a card (toggles if already present)",
{
cardPublicId: z.string().describe("The card's public ID"),
labelPublicId: z.string().describe("The label's public ID"),
},
async ({ cardPublicId, labelPublicId }) => {
const data = await kanRequest(
"PUT",
`/cards/${cardPublicId}/labels/${labelPublicId}`,
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"toggle_card_member",
"Add or remove a member assignment on a card (toggles if already assigned)",
{
cardPublicId: z.string().describe("The card's public ID"),
workspaceMemberPublicId: z.string().describe("The workspace member's public ID"),
},
async ({ cardPublicId, workspaceMemberPublicId }) => {
const data = await kanRequest(
"PUT",
`/cards/${cardPublicId}/members/${workspaceMemberPublicId}`,
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
}

View File

@@ -0,0 +1,83 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { kanRequest } from "../client.js";
export function registerChecklistTools(server: McpServer): void {
server.tool(
"create_checklist",
"Add a checklist to a card",
{
cardPublicId: z.string().describe("The card's public ID"),
name: z.string().describe("Checklist name"),
},
async ({ cardPublicId, name }) => {
const data = await kanRequest("POST", `/cards/${cardPublicId}/checklists`, { name });
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"update_checklist",
"Rename a checklist",
{
checklistPublicId: z.string().describe("The checklist's public ID"),
name: z.string().describe("New checklist name"),
},
async ({ checklistPublicId, name }) => {
const data = await kanRequest("PUT", `/checklists/${checklistPublicId}`, { name });
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"delete_checklist",
"Delete a checklist and all its items",
{ checklistPublicId: z.string().describe("The checklist's public ID") },
async ({ checklistPublicId }) => {
const data = await kanRequest("DELETE", `/checklists/${checklistPublicId}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"create_checklist_item",
"Add an item to a checklist",
{
checklistPublicId: z.string().describe("The checklist's public ID"),
title: z.string().describe("Item title"),
},
async ({ checklistPublicId, title }) => {
const data = await kanRequest("POST", `/checklists/${checklistPublicId}/items`, { title });
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"update_checklist_item",
"Update a checklist item's title, completion status, or position",
{
checklistItemPublicId: z.string().describe("The checklist item's public ID"),
title: z.string().optional().describe("New item title"),
isCompleted: z.boolean().optional().describe("Mark item as completed or not"),
index: z.number().int().optional().describe("New position index"),
},
async ({ checklistItemPublicId, title, isCompleted, index }) => {
const data = await kanRequest("PATCH", `/checklists/items/${checklistItemPublicId}`, {
title,
isCompleted,
index,
});
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"delete_checklist_item",
"Delete a checklist item",
{ checklistItemPublicId: z.string().describe("The checklist item's public ID") },
async ({ checklistItemPublicId }) => {
const data = await kanRequest("DELETE", `/checklists/items/${checklistItemPublicId}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
}

View File

@@ -0,0 +1,101 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { kanRequest } from "../client.js";
// Preset colour palette supported by the Kan UI.
// Mirrors packages/shared/src/constants/colours.ts. Keep in sync.
const COLOUR_PRESETS = {
Teal: "#0d9488",
Green: "#65a30d",
Blue: "#0284c7",
Purple: "#4f46e5",
Yellow: "#ca8a04",
Orange: "#ea580c",
Red: "#dc2626",
Pink: "#db2777",
} as const;
const colourNames = Object.keys(COLOUR_PRESETS) as [
keyof typeof COLOUR_PRESETS,
...(keyof typeof COLOUR_PRESETS)[],
];
const colourNameSchema = z.enum(colourNames);
const presetDescription = `One of the preset colour names: ${colourNames.join(", ")}`;
function resolveColourCode(
colour: keyof typeof COLOUR_PRESETS | undefined,
colourCode: string | undefined,
): string | undefined {
if (colourCode) return colourCode;
if (colour) return COLOUR_PRESETS[colour];
return undefined;
}
export function registerLabelTools(server: McpServer): void {
server.tool(
"get_label",
"Get a label by its public ID",
{ labelPublicId: z.string().describe("The label's public ID") },
async ({ labelPublicId }) => {
const data = await kanRequest("GET", `/labels/${labelPublicId}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"create_label",
`Create a label for a board. Pick a colour by preset name (${colourNames.join(", ")}) or pass an explicit 7-char hex via colourCode. Defaults to Teal.`,
{
boardPublicId: z.string().describe("The board's public ID"),
name: z.string().describe("Label name"),
colour: colourNameSchema.optional().describe(presetDescription),
colourCode: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional()
.describe("Explicit 7-char hex colour (e.g. #0d9488). Overrides `colour` if both are set."),
},
async ({ boardPublicId, name, colour, colourCode }) => {
const resolved = resolveColourCode(colour, colourCode) ?? COLOUR_PRESETS.Teal;
const data = await kanRequest("POST", "/labels", {
boardPublicId,
name,
colourCode: resolved,
});
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"update_label",
"Update a label's name or colour. Pick a colour by preset name or pass an explicit 7-char hex.",
{
labelPublicId: z.string().describe("The label's public ID"),
name: z.string().optional().describe("New label name"),
colour: colourNameSchema.optional().describe(presetDescription),
colourCode: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional()
.describe("Explicit 7-char hex colour. Overrides `colour` if both are set."),
},
async ({ labelPublicId, name, colour, colourCode }) => {
const resolved = resolveColourCode(colour, colourCode);
const body: Record<string, unknown> = {};
if (name !== undefined) body.name = name;
if (resolved !== undefined) body.colourCode = resolved;
const data = await kanRequest("PUT", `/labels/${labelPublicId}`, body);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"delete_label",
"Delete a label",
{ labelPublicId: z.string().describe("The label's public ID") },
async ({ labelPublicId }) => {
const data = await kanRequest("DELETE", `/labels/${labelPublicId}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
}

View File

@@ -0,0 +1,42 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { kanRequest } from "../client.js";
export function registerListTools(server: McpServer): void {
server.tool(
"create_list",
"Create a new list inside a board",
{
boardPublicId: z.string().describe("The board's public ID"),
name: z.string().describe("List name"),
},
async ({ boardPublicId, name }) => {
const data = await kanRequest("POST", "/lists", { boardPublicId, name });
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"update_list",
"Update a list's name or position",
{
listPublicId: z.string().describe("The list's public ID"),
name: z.string().optional().describe("New list name"),
index: z.number().int().optional().describe("New position index"),
},
async ({ listPublicId, name, index }) => {
const data = await kanRequest("PUT", `/lists/${listPublicId}`, { name, index });
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"delete_list",
"Delete a list and all its cards",
{ listPublicId: z.string().describe("The list's public ID") },
async ({ listPublicId }) => {
const data = await kanRequest("DELETE", `/lists/${listPublicId}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
}

View File

@@ -0,0 +1,90 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { kanRequest } from "../client.js";
export function registerMemberTools(server: McpServer): void {
server.tool(
"invite_member",
"Invite a user to a workspace by email",
{
workspacePublicId: z.string().describe("The workspace's public ID"),
email: z.string().email().describe("Email address to invite"),
role: z
.enum(["admin", "member", "guest"])
.optional()
.describe("Role to assign (default: member)"),
},
async ({ workspacePublicId, email, role }) => {
const data = await kanRequest(
"POST",
`/workspaces/${workspacePublicId}/members/invite`,
{ email, role },
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"remove_member",
"Remove a member from a workspace",
{
workspacePublicId: z.string().describe("The workspace's public ID"),
memberPublicId: z.string().describe("The workspace member's public ID"),
},
async ({ workspacePublicId, memberPublicId }) => {
const data = await kanRequest(
"DELETE",
`/workspaces/${workspacePublicId}/members/${memberPublicId}`,
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"update_member_role",
"Change the role of a workspace member",
{
workspacePublicId: z.string().describe("The workspace's public ID"),
memberPublicId: z.string().describe("The workspace member's public ID"),
role: z.enum(["admin", "member", "guest"]).describe("New role"),
},
async ({ workspacePublicId, memberPublicId, role }) => {
const data = await kanRequest(
"PUT",
`/workspaces/${workspacePublicId}/members/${memberPublicId}/role`,
{ role },
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"get_workspace_invite_link",
"Get the active invite link for a workspace",
{ workspacePublicId: z.string().describe("The workspace's public ID") },
async ({ workspacePublicId }) => {
const data = await kanRequest("GET", `/workspaces/${workspacePublicId}/invite`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"create_workspace_invite_link",
"Create a new invite link for a workspace (7-day expiry)",
{ workspacePublicId: z.string().describe("The workspace's public ID") },
async ({ workspacePublicId }) => {
const data = await kanRequest("POST", `/workspaces/${workspacePublicId}/invites`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"deactivate_workspace_invite_links",
"Deactivate all active invite links for a workspace",
{ workspacePublicId: z.string().describe("The workspace's public ID") },
async ({ workspacePublicId }) => {
const data = await kanRequest("DELETE", `/workspaces/${workspacePublicId}/invites`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
}

View File

@@ -0,0 +1,121 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { kanRequest } from "../client.js";
export function registerWorkspaceTools(server: McpServer): void {
server.tool(
"list_workspaces",
"List all workspaces the authenticated user belongs to. Call this first to resolve a workspace name to its publicId before calling any other workspace-scoped tool.",
{},
async () => {
const data = await kanRequest("GET", "/workspaces");
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"find_workspace_by_name",
"Find a workspace by its name (case-insensitive). Returns the matching workspace including its publicId. Use this whenever you only know the workspace name and need its publicId.",
{ name: z.string().describe("Workspace name to search for") },
async ({ name }) => {
const workspaces = await kanRequest<{ publicId: string; name: string }[]>("GET", "/workspaces");
const match = workspaces.find(
(w) => w.name.toLowerCase() === name.toLowerCase(),
);
if (!match) {
const names = workspaces.map((w) => w.name).join(", ");
return {
content: [
{
type: "text",
text: `No workspace found with name "${name}". Available workspaces: ${names}`,
},
],
};
}
return { content: [{ type: "text", text: JSON.stringify(match, null, 2) }] };
},
);
server.tool(
"get_workspace",
"Get a workspace by its public ID, including its members",
{ workspacePublicId: z.string().describe("The workspace's public ID") },
async ({ workspacePublicId }) => {
const data = await kanRequest("GET", `/workspaces/${workspacePublicId}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"get_workspace_by_slug",
"Get a workspace by its slug, including its boards",
{ workspaceSlug: z.string().describe("The workspace slug") },
async ({ workspaceSlug }) => {
const data = await kanRequest("GET", `/workspaces/${workspaceSlug}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"create_workspace",
"Create a new workspace",
{
name: z.string().describe("Workspace name"),
slug: z.string().optional().describe("URL-friendly slug (auto-generated if omitted)"),
},
async ({ name, slug }) => {
const data = await kanRequest("POST", "/workspaces", { name, slug });
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"update_workspace",
"Update a workspace's name or slug",
{
workspacePublicId: z.string().describe("The workspace's public ID"),
name: z.string().optional().describe("New workspace name"),
slug: z.string().optional().describe("New workspace slug"),
},
async ({ workspacePublicId, name, slug }) => {
const data = await kanRequest("PUT", `/workspaces/${workspacePublicId}`, { name, slug });
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"delete_workspace",
"Permanently delete a workspace",
{ workspacePublicId: z.string().describe("The workspace's public ID") },
async ({ workspacePublicId }) => {
const data = await kanRequest("DELETE", `/workspaces/${workspacePublicId}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"search_workspace",
"Search for boards and cards by title within a workspace",
{
workspacePublicId: z.string().describe("The workspace's public ID"),
query: z.string().describe("Search query string"),
},
async ({ workspacePublicId, query }) => {
const params = new URLSearchParams({ query });
const data = await kanRequest("GET", `/workspaces/${workspacePublicId}/search?${params}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
server.tool(
"check_workspace_slug_availability",
"Check whether a workspace slug is available",
{ slug: z.string().describe("Slug to check") },
async ({ slug }) => {
const params = new URLSearchParams({ slug });
const data = await kanRequest("GET", `/workspaces/check-slug-availability?${params}`);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
);
}

View File

@@ -0,0 +1,14 @@
{
"extends": "@kan/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"noEmit": false,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"declaration": false,
"tsBuildInfoFile": ".cache/tsbuildinfo.json"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -54,3 +54,14 @@ export const hasUnlimitedSeats = (
const activeSubscriptions = getActiveSubscriptions(subscriptions);
return activeSubscriptions.some((sub) => sub.unlimitedSeats);
};
export const getSeatLimit = (
subscriptions: Subscription[] | undefined,
): number | null => {
const activeSubscriptions = getActiveSubscriptions(subscriptions);
const partnerSub = activeSubscriptions.find(
(sub) =>
sub.partnerTier !== null && !sub.unlimitedSeats && sub.seats !== null,
);
return partnerSub?.seats ?? null;
};

407
pnpm-lock.yaml generated
View File

@@ -533,6 +533,22 @@ importers:
specifier: 'catalog:'
version: 5.9.2
packages/mcp:
dependencies:
'@modelcontextprotocol/sdk':
specifier: ^1.11.0
version: 1.29.0(zod@3.25.76)
zod:
specifier: 'catalog:'
version: 3.25.76
devDependencies:
'@kan/tsconfig':
specifier: workspace:*
version: link:../../tooling/typescript
typescript:
specifier: 'catalog:'
version: 5.9.2
packages/shared:
dependencies:
'@aws-sdk/client-s3':
@@ -2347,6 +2363,12 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
'@hookform/resolvers@3.10.0':
resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==}
peerDependencies:
@@ -2841,6 +2863,16 @@ packages:
'@mintlify/models': 0.0.25
openapi-types: 12.x
'@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
zod: ^3.25 || ^4.0
peerDependenciesMeta:
'@cfworker/json-schema':
optional: true
'@next/env@15.5.9':
resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==}
@@ -4349,6 +4381,10 @@ packages:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
acorn-import-phases@1.0.4:
resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==}
engines: {node: '>=10.13.0'}
@@ -4636,6 +4672,10 @@ packages:
bl@5.1.0:
resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==}
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
@@ -4920,12 +4960,24 @@ packages:
constant-case@2.0.0:
resolution: {integrity: sha512-eS0N9WwmjTqrOmR3o83F5vW8Z+9R1HnVz3xmzT2PMFug9ly+Au/fxRWlEBSb6LcZwspSsEn9Xs1uw9YgzAg1EQ==}
content-disposition@1.1.0:
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
engines: {node: '>=18'}
content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie-es@1.2.2:
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
@@ -5306,6 +5358,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
electron-to-chromium@1.5.211:
resolution: {integrity: sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==}
@@ -5324,6 +5379,10 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
encodeurl@2.0.0:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
@@ -5564,10 +5623,22 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
eventsource-parser@3.0.8:
resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==}
engines: {node: '>=18.0.0'}
eventsource@3.0.7:
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
engines: {node: '>=18.0.0'}
execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
@@ -5576,6 +5647,16 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
express-rate-limit@8.4.1:
resolution: {integrity: sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
@@ -5653,6 +5734,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -5689,6 +5774,10 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
framer-motion@12.26.2:
resolution: {integrity: sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA==}
peerDependencies:
@@ -5703,6 +5792,10 @@ packages:
react-dom:
optional: true
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
@@ -5786,25 +5879,29 @@ packages:
glob@10.3.10:
resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==}
engines: {node: '>=16 || 14 >=14.17'}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@10.4.5:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@11.0.3:
resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==}
engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@11.1.0:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
@@ -5890,6 +5987,10 @@ packages:
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
hono@4.12.16:
resolution: {integrity: sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==}
engines: {node: '>=16.9.0'}
html-to-text@9.0.5:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
@@ -5904,6 +6005,10 @@ packages:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -5924,6 +6029,10 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -5992,6 +6101,14 @@ packages:
resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
engines: {node: '>= 12'}
ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
iron-webcrypto@1.2.1:
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
@@ -6132,6 +6249,9 @@ packages:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -6576,9 +6696,17 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
merge-descriptors@2.0.0:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -6870,6 +6998,10 @@ packages:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@@ -7025,6 +7157,10 @@ packages:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -7117,6 +7253,10 @@ packages:
parseley@0.12.1:
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
pascal-case@2.0.1:
resolution: {integrity: sha512-qjS4s8rBOJa2Xm0jmxXiyh1+OFf6ekCWOvUaRgAQSktzlTbMotS0nmG9gyYAybCWBcuP4fsBeRCKNwGBnMe2OQ==}
@@ -7150,6 +7290,9 @@ packages:
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
engines: {node: 20 || >=22}
path-to-regexp@8.4.2:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
@@ -7237,6 +7380,10 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
@@ -7475,6 +7622,10 @@ packages:
prosemirror-view@1.40.1:
resolution: {integrity: sha512-pbwUjt3G7TlsQQHDiYSupWBhJswpLVB09xXm1YiJPdkjkh9Pe7Y51XdLh5VWIZmROLY8UpUpG03lkdhm9lzIBA==}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
proxy-agent@6.5.0:
resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
engines: {node: '>= 14'}
@@ -7502,6 +7653,10 @@ packages:
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
engines: {node: '>=0.6'}
qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -7517,6 +7672,10 @@ packages:
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
rate-limiter-flexible@9.0.1:
resolution: {integrity: sha512-sO+QdoGPCxroi4VkO2FIVjfUGuexhRkBc9ROHqu5eVEEz+oPHzQqvCc25ajFfMUBosbNGb6qpNa8xmxH9YNZsg==}
@@ -7524,6 +7683,10 @@ packages:
resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
engines: {node: '>= 0.8'}
raw-body@3.0.2:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
@@ -7760,6 +7923,10 @@ packages:
rou3@0.7.11:
resolution: {integrity: sha512-ELguG3ENDw5NKNmWHO3OGEjcgdxkCNvnMR22gKHEgRXuwiriap5RIYdummOaOiqUNcC5yU5txGCHWNm7KlHuAA==}
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
run-async@2.4.1:
resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
engines: {node: '>=0.12.0'}
@@ -7843,12 +8010,20 @@ packages:
engines: {node: '>=10'}
hasBin: true
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
sentence-case@2.1.1:
resolution: {integrity: sha512-ENl7cYHaK/Ktwk5OTD+aDbQ3uC8IByu/6Bkg+HDv8Mm+XnBnppVNalcfJTNsp1ibstKh030/JKQQWglDvtKwEQ==}
serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
@@ -7992,6 +8167,10 @@ packages:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
@@ -8380,6 +8559,10 @@ packages:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
type-is@2.0.1:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -8815,6 +8998,11 @@ packages:
peerDependencies:
zod: ^3.24.1
zod-to-json-schema@3.25.2:
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies:
zod: ^3.25.28 || ^4
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -10678,6 +10866,10 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
use-sync-external-store: 1.5.0(react@18.3.1)
'@hono/node-server@1.19.14(hono@4.12.16)':
dependencies:
hono: 4.12.16
'@hookform/resolvers@3.10.0(react-hook-form@7.62.0(react@18.3.1))':
dependencies:
react-hook-form: 7.62.0(react@18.3.1)
@@ -11192,6 +11384,28 @@ snapshots:
zod: 3.25.76
zod-to-json-schema: 3.24.6(zod@3.25.76)
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.14(hono@4.12.16)
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
content-type: 1.0.5
cors: 2.8.5
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.8
express: 5.2.1
express-rate-limit: 8.4.1(express@5.2.1)
hono: 4.12.16
jose: 6.1.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
transitivePeerDependencies:
- supports-color
'@next/env@15.5.9': {}
'@next/env@16.0.8':
@@ -12929,6 +13143,11 @@ snapshots:
mime-types: 2.1.35
negotiator: 0.6.3
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
negotiator: 1.0.0
acorn-import-phases@1.0.4(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@@ -13209,6 +13428,20 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.3
http-errors: 2.0.0
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.15.1
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
- supports-color
boolbase@1.0.0: {}
bowser@2.12.1: {}
@@ -13514,10 +13747,16 @@ snapshots:
snake-case: 2.1.0
upper-case: 1.1.3
content-disposition@1.1.0: {}
content-type@1.0.5: {}
convert-source-map@2.0.0: {}
cookie-es@1.2.2: {}
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
copy-anything@3.0.5:
@@ -13801,6 +14040,8 @@ snapshots:
eastasianwidth@0.2.0: {}
ee-first@1.1.1: {}
electron-to-chromium@1.5.211: {}
electron-to-chromium@1.5.240: {}
@@ -13813,6 +14054,8 @@ snapshots:
emoji-regex@9.2.2: {}
encodeurl@2.0.0: {}
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
@@ -14345,8 +14588,16 @@ snapshots:
esutils@2.0.3: {}
etag@1.8.1: {}
events@3.3.0: {}
eventsource-parser@3.0.8: {}
eventsource@3.0.7:
dependencies:
eventsource-parser: 3.0.8
execa@5.1.1:
dependencies:
cross-spawn: 7.0.6
@@ -14361,6 +14612,44 @@ snapshots:
expect-type@1.3.0: {}
express-rate-limit@8.4.1(express@5.2.1):
dependencies:
express: 5.2.1
ip-address: 10.1.0
express@5.2.1:
dependencies:
accepts: 2.0.0
body-parser: 2.2.2
content-disposition: 1.1.0
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.3
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
finalhandler: 2.1.1
fresh: 2.0.0
http-errors: 2.0.0
merge-descriptors: 2.0.0
mime-types: 3.0.2
on-finished: 2.4.1
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.14.0
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
serve-static: 2.2.1
statuses: 2.0.1
type-is: 2.0.1
vary: 1.1.2
transitivePeerDependencies:
- supports-color
exsolve@1.0.8: {}
extend-shallow@2.0.1:
@@ -14433,6 +14722,17 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
parseurl: 1.3.3
statuses: 2.0.1
transitivePeerDependencies:
- supports-color
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -14466,6 +14766,8 @@ snapshots:
format@0.2.2: {}
forwarded@0.2.0: {}
framer-motion@12.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
motion-dom: 12.26.2
@@ -14475,6 +14777,8 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
fresh@2.0.0: {}
fs-extra@10.1.0:
dependencies:
graceful-fs: 4.2.11
@@ -14725,6 +15029,8 @@ snapshots:
dependencies:
react-is: 16.13.1
hono@4.12.16: {}
html-to-text@9.0.5:
dependencies:
'@selderee/plugin-htmlparser2': 0.11.0
@@ -14750,6 +15056,14 @@ snapshots:
statuses: 2.0.1
toidentifier: 1.0.1
http-errors@2.0.1:
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.2
toidentifier: 1.0.1
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@@ -14774,6 +15088,10 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {}
ignore@5.3.2: {}
@@ -14877,6 +15195,10 @@ snapshots:
ip-address@10.0.1: {}
ip-address@10.1.0: {}
ipaddr.js@1.9.1: {}
iron-webcrypto@1.2.1: {}
is-absolute-url@4.0.1: {}
@@ -14996,6 +15318,8 @@ snapshots:
is-plain-object@5.0.0: {}
is-promise@4.0.0: {}
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -15553,8 +15877,12 @@ snapshots:
media-typer@0.3.0: {}
media-typer@1.1.0: {}
memoize-one@5.2.1: {}
merge-descriptors@2.0.0: {}
merge-stream@2.0.0: {}
merge2@1.4.1: {}
@@ -16058,6 +16386,8 @@ snapshots:
negotiator@0.6.3: {}
negotiator@1.0.0: {}
neo-async@2.6.2: {}
netmask@2.0.2: {}
@@ -16224,6 +16554,10 @@ snapshots:
on-exit-leak-free@2.1.2: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -16376,6 +16710,8 @@ snapshots:
leac: 0.6.0
peberminta: 0.9.0
parseurl@1.3.3: {}
pascal-case@2.0.1:
dependencies:
camel-case: 3.0.0
@@ -16408,6 +16744,8 @@ snapshots:
lru-cache: 11.2.4
minipass: 7.1.2
path-to-regexp@8.4.2: {}
path-type@4.0.0: {}
pathe@2.0.3: {}
@@ -16503,6 +16841,8 @@ snapshots:
pirates@4.0.7: {}
pkce-challenge@5.0.1: {}
pkg-types@2.3.0:
dependencies:
confbox: 0.2.2
@@ -16726,6 +17066,11 @@ snapshots:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
proxy-agent@6.5.0:
dependencies:
agent-base: 7.1.4
@@ -16758,6 +17103,10 @@ snapshots:
dependencies:
side-channel: 1.1.0
qs@6.15.1:
dependencies:
side-channel: 1.1.0
queue-microtask@1.2.3: {}
quick-format-unescaped@4.0.4: {}
@@ -16770,6 +17119,8 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
range-parser@1.2.1: {}
rate-limiter-flexible@9.0.1: {}
raw-body@2.5.2:
@@ -16779,6 +17130,13 @@ snapshots:
iconv-lite: 0.4.24
unpipe: 1.0.0
raw-body@3.0.2:
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.7.2
unpipe: 1.0.0
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
@@ -17129,6 +17487,16 @@ snapshots:
rou3@0.7.11: {}
router@2.2.0:
dependencies:
debug: 4.4.3
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
path-to-regexp: 8.4.2
transitivePeerDependencies:
- supports-color
run-async@2.4.1: {}
run-async@3.0.0: {}
@@ -17206,6 +17574,22 @@ snapshots:
semver@7.7.3: {}
send@1.2.1:
dependencies:
debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
fresh: 2.0.0
http-errors: 2.0.1
mime-types: 3.0.2
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
sentence-case@2.1.1:
dependencies:
no-case: 2.3.2
@@ -17215,6 +17599,15 @@ snapshots:
dependencies:
randombytes: 2.1.0
serve-static@2.2.1:
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
send: 1.2.1
transitivePeerDependencies:
- supports-color
set-cookie-parser@2.7.2: {}
set-function-length@1.2.2:
@@ -17436,6 +17829,8 @@ snapshots:
statuses@2.0.1: {}
statuses@2.0.2: {}
std-env@3.10.0: {}
stdin-discarder@0.1.0:
@@ -17901,6 +18296,12 @@ snapshots:
media-typer: 0.3.0
mime-types: 2.1.35
type-is@2.0.1:
dependencies:
content-type: 1.0.5
media-typer: 1.1.0
mime-types: 3.0.2
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -18478,6 +18879,10 @@ snapshots:
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.2(zod@3.25.76):
dependencies:
zod: 3.25.76
zod@3.25.76: {}
zod@4.1.13: {}

View File

@@ -3,28 +3,49 @@
"ui": "tui",
"tasks": {
"topo": {
"dependsOn": ["^topo"]
"dependsOn": [
"^topo"
]
},
"build": {
"dependsOn": ["^build"],
"outputs": [".cache/tsbuildinfo.json", "dist/**"]
"dependsOn": [
"^build"
],
"outputs": [
".cache/tsbuildinfo.json",
"dist/**"
]
},
"dev": {
"dependsOn": ["^dev"],
"dependsOn": [
"^dev"
],
"cache": false,
"persistent": false
},
"format": {
"outputs": [".cache/.prettiercache"],
"outputs": [
".cache/.prettiercache"
],
"outputLogs": "new-only"
},
"lint": {
"dependsOn": ["^topo", "^build"],
"outputs": [".cache/.eslintcache"]
"dependsOn": [
"^topo",
"^build"
],
"outputs": [
".cache/.eslintcache"
]
},
"typecheck": {
"dependsOn": ["^topo", "^build"],
"outputs": [".cache/tsbuildinfo.json"]
"dependsOn": [
"^topo",
"^build"
],
"outputs": [
".cache/tsbuildinfo.json"
]
},
"clean": {
"cache": false
@@ -132,7 +153,9 @@
"REDIS_URL",
"LOG_LEVEL",
"AXIOM_TOKEN",
"AXIOM_DATASET"
"AXIOM_DATASET",
"KAN_BASE_URL",
"KAN_API_TOKEN"
],
"globalPassThroughEnv": [
"NODE_ENV",