Compare commits

...

36 Commits

Author SHA1 Message Date
Henry
c28fbf2668 feat: add log drain 2026-03-19 21:14:19 +00:00
Henry
67cf523ee8 fix: API key authentication and REST handler errors (#446) 2026-03-19 12:20:19 +00:00
hjball
0b45ceff7f chore: update translations 2026-03-16 14:32:37 +00:00
Henry
76b2c58461 fix: security improvements to checklist input (#444)
* feat: sanitize input for checklists

* feat: add basic editor

* feat: use plain text editor for checklist items

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

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

* removing trash

* moving font menu

* chore: revert port change in compose

---------

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

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

Fixes #361

* feat: add Saturday as option

* chore: fix migration order

* chore: fix merge

---------

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

Closes #320

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

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

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

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

Addresses review feedback on PR #349

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

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

Add tRPC router for managing workspace webhooks:

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

Depends on #391 (DB schema & repository).

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

* refactor(api): use assertPermission instead of assertUserInWorkspace

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(web): add webhook management UI

Add settings page for managing workspace webhooks:

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

Depends on #393 (CRUD API router).

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

Add tRPC router for managing workspace webhooks:

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

Depends on #391 (DB schema & repository).

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

* refactor(api): use assertPermission instead of assertUserInWorkspace

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

* added a web app manifest

* added a screenshot for richer install on mobile

* added a screenshot for richer install ui on desktop

* added a document to include the manifest

* fixed the command to generate a better auth secret

* moved the link tag directly into the PageHead component
2026-03-10 22:13:58 +00:00
hjball
dfcdc5e47e chore: update translations 2026-03-10 22:00:41 +00:00
kOaDT
32e77e0291 fix: preserve board scroll position when navigating back from a card (#431) 2026-03-10 21:59:22 +00:00
Henry
a6780e3e28 fix: show correct highlighting in pricing comparison table 2026-03-03 22:31:08 +00:00
hjball
2c5195a2b8 chore: update translations 2026-03-03 22:17:28 +00:00
Henry
793fa30162 fix: adjust icon size in CardPage component 2026-03-03 22:16:09 +00:00
Henry
af63719de5 fix: swap latest to only publish on version tags and add edge for builds (#428) 2026-03-03 22:14:35 +00:00
hjball
52fd624b81 chore: update translations 2026-03-03 21:37:46 +00:00
Henry
b4d4810f45 fix: migration order (#427) 2026-03-03 21:36:31 +00:00
hjball
239d152340 chore: compile translations 2026-03-01 22:41:43 +00:00
hjball
f294760747 chore: update translations 2026-03-01 22:41:39 +00:00
Morfixx
280d8f66dd feat: initial github integration with importing projects (#421)
* feat: initial github integration with importing projects

* fix: remove unused args

* chore: remove duplicate col

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-03-01 22:39:25 +00:00
114 changed files with 18204 additions and 2597 deletions

View File

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

View File

@@ -6,8 +6,6 @@ name: Docker
# documentation.
on:
schedule:
- cron: "21 21 * * *"
push:
# Only trigger on tags (releases), not main branch pushes
# Main branch builds are handled by workflow_run after Translate completes
@@ -84,7 +82,8 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
type=raw,value=edge,enable={{is_default_branch}}
- name: Extract Docker metadata (migrate)
id: meta-migrate
@@ -97,7 +96,8 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
type=raw,value=edge,enable={{is_default_branch}}
# Extract version from git tag or ref
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234

2
.gitignore vendored
View File

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

View File

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

View File

@@ -203,6 +203,7 @@ pnpm dev
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `S3_AVATAR_UPLOAD_LIMIT` | Maximum avatar file size in bytes | For file uploads | `2097152` (2MB) |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
@@ -212,6 +213,7 @@ pnpm dev
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
| `LOG_LEVEL` | Log verbosity level (debug, info, warn, error) | No (defaults to debug in dev, info in prod) | `info` |
See `.env.example` for a complete list of supported environment variables.

View File

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

View File

@@ -39,6 +39,10 @@ checksums:
0xWkkH/placeholders/boardCount/0: 8cc7c9ba5252e9266c6eb8434fae00f3
0xWkkH/origin/0/0: 3c57a918258af4b44c187f58d203345f
0xWkkH/translation: 72358c550bd1a99fcb6e8952f0f947ef
yndBF%2B/message: 9fb995d992256a9db7822d430ad75ba0
yndBF%2B/placeholders/projectCount/0: 23257c47018d21aeae42cf5bd0aa9932
yndBF%2B/origin/0/0: 3c57a918258af4b44c187f58d203345f
yndBF%2B/translation: 9fb995d992256a9db7822d430ad75ba0
9lFfqv/message: 6cb28ff89203e2eca3fdd69396dd00d6
9lFfqv/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
9lFfqv/translation: 6cb28ff89203e2eca3fdd69396dd00d6
@@ -66,6 +70,9 @@ checksums:
gkxGkF/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
gkxGkF/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
gkxGkF/translation: f5f537ac080c6184864ffaae7a9fcf4f
4obmDr/message: 3d918420983fcb0d66a6b3df89a5294e
4obmDr/origin/0/0: 01b2fdc8e77a462c0ab211caecec400f
4obmDr/translation: 3d918420983fcb0d66a6b3df89a5294e
"%2Frn4RE/message": d405b83b0d631cb72f4347c10bcbb643
"%2Frn4RE/origin/0/0": 09a6a32f9a6b415e81b4d2f8181a0e6b
"%2Frn4RE/translation": d405b83b0d631cb72f4347c10bcbb643
@@ -81,9 +88,11 @@ checksums:
7L01XJ/message: c46571856723b03262fd33f511116298
7L01XJ/origin/0/0: feacdaf2791aaddec128f463ffa2e807
7L01XJ/translation: c46571856723b03262fd33f511116298
F6pfE9/translation: 3e1ec025c4a50830bbb9ad57a176630a
F6pfE9/message: 3e1ec025c4a50830bbb9ad57a176630a
F6pfE9/origin/0/0: 4b563046ba98abb3777fb1c3560866aa
F6pfE9/origin/1/0: f64cd62b620f454998523af2a4330278
F6pfE9/origin/2/0: dc1a240d2d0cccb102c67ca311d4e457
F6pfE9/translation: 3e1ec025c4a50830bbb9ad57a176630a
XJOV1Y/message: 1948763de8e531483a798b68195e297e
XJOV1Y/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
XJOV1Y/origin/1/0: 9c8b544f5db130cd7c5eda9064ba3957
@@ -124,6 +133,9 @@ checksums:
pBsoKL/message: 532155acb0a46f2b6c37e15afb3ad21e
pBsoKL/origin/0/0: 0ad2af981579a62b084b3d41462c5546
pBsoKL/translation: 532155acb0a46f2b6c37e15afb3ad21e
cWXW%2B7/message: f8fa42d8c95936075e05ad238fd03467
cWXW%2B7/origin/0/0: 566b5982085ae649b2be0c614f666591
cWXW%2B7/translation: f8fa42d8c95936075e05ad238fd03467
bmXKoX/message: 42b0488d570626dc887c58ff669c953c
bmXKoX/placeholders/0/0: ea5a6955d34117338ea90173f2aa1f02
bmXKoX/placeholders/labelList/0: 19a2dd6c58e803b03e6c301dc363eed6
@@ -211,6 +223,12 @@ checksums:
WnEwDO/message: 2959fd276248208b65cb27ed46b20135
WnEwDO/origin/0/0: d6f5120f072a821219608624f361384e
WnEwDO/translation: 2959fd276248208b65cb27ed46b20135
j1%2BHPc/message: baf4c3cd580291f41458f9ed28102c49
j1%2BHPc/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
j1%2BHPc/translation: baf4c3cd580291f41458f9ed28102c49
Dz63YI/message: 8e43abcd6e039b51084937e4829f58ad
Dz63YI/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
Dz63YI/translation: 8e43abcd6e039b51084937e4829f58ad
WmPhYW/message: 0aa3973b860c1faf8d9123aebf567e40
WmPhYW/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
WmPhYW/translation: 0aa3973b860c1faf8d9123aebf567e40
@@ -248,16 +266,20 @@ checksums:
yb%2Ffjw/message: 2f5426445800ecd5a3102aac940d459c
yb%2Ffjw/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
yb%2Ffjw/translation: 2f5426445800ecd5a3102aac940d459c
aKJHG%2B/translation: 63de765f7fd7bc5238a3e36532306b6b
aKJHG%2B/message: 63de765f7fd7bc5238a3e36532306b6b
aKJHG%2B/origin/0/0: 0ad2af981579a62b084b3d41462c5546
TdfEV7/translation: cf5127ecfd7e43a35466a1ba5fe16450
aKJHG%2B/translation: 63de765f7fd7bc5238a3e36532306b6b
TdfEV7/message: cf5127ecfd7e43a35466a1ba5fe16450
TdfEV7/origin/0/0: 4b563046ba98abb3777fb1c3560866aa
TdfEV7/translation: cf5127ecfd7e43a35466a1ba5fe16450
lo8xBK/message: f9b67026c5ef86ac4d5944380d571961
lo8xBK/placeholders/entityLabel/0: 5745fa1af326b9ac2b140cbbef90cdeb
lo8xBK/origin/0/0: 68450df287fb46ea629e818732c1291a
lo8xBK/translation: f9b67026c5ef86ac4d5944380d571961
Ypy5pZ/message: e4f0c7b9475b179d3856c0fe28aa4f21
Ypy5pZ/placeholders/webhookName/0: b1f7ad11b42fd7d0142b9ae1fd269e35
Ypy5pZ/origin/0/0: a1fde3fcce608870830f168f027727ff
Ypy5pZ/translation: e4f0c7b9475b179d3856c0fe28aa4f21
BhDZlc/message: 27ba2854202dd77c51e78c64e5c93946
BhDZlc/placeholders/0/0: 7c9ad8179019e516873e06573980408a
BhDZlc/origin/0/0: 83911e3eacbad4583e2b1647a784d154
@@ -341,9 +363,9 @@ checksums:
YhvWXb/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
YhvWXb/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
YhvWXb/translation: 336a8c70a91adc49c15266167ee84cc0
i0ZMQl/translation: f739058266a2cb6a3d7cca14de09c69a
i0ZMQl/message: f739058266a2cb6a3d7cca14de09c69a
i0ZMQl/origin/0/0: 0ad2af981579a62b084b3d41462c5546
i0ZMQl/translation: f739058266a2cb6a3d7cca14de09c69a
wewm3j/message: 985c0177744ec56d8dac84f66ec284ca
wewm3j/origin/0/0: bcd95e286f12800a3e7ee0a71feb3ca5
wewm3j/translation: 985c0177744ec56d8dac84f66ec284ca
@@ -356,9 +378,9 @@ checksums:
XNxYxq/message: b0e6a79e1751b734e1060a1034a893d6
XNxYxq/origin/0/0: c0b59968b2b234167c488a05cc38710b
XNxYxq/translation: b0e6a79e1751b734e1060a1034a893d6
tbNcu4/translation: 3817a16b436504bb8d96ca5ee62078cc
tbNcu4/message: 3817a16b436504bb8d96ca5ee62078cc
tbNcu4/origin/0/0: 0ad2af981579a62b084b3d41462c5546
tbNcu4/translation: 3817a16b436504bb8d96ca5ee62078cc
Xid3K6/message: ba018946ffdf85e55d701a9c27595b4d
Xid3K6/origin/0/0: 2e937f7560901c3de71a592b4fd80f34
Xid3K6/translation: ba018946ffdf85e55d701a9c27595b4d
@@ -383,9 +405,9 @@ checksums:
8TveY%2B/origin/0/0: 94d3e20c29bb517dd394711d7524e478
8TveY%2B/origin/1/0: c0b59968b2b234167c488a05cc38710b
8TveY%2B/translation: 8fff51c5872e051beb9b718336665e80
0RE1AJ/translation: c9a1699f5037acf87aaa6515d0f16910
0RE1AJ/message: c9a1699f5037acf87aaa6515d0f16910
0RE1AJ/origin/0/0: f78c0a7870158f5dab5216bcd6092e49
0RE1AJ/translation: c9a1699f5037acf87aaa6515d0f16910
ZDo4HN/message: 736332f2e4488609e42d2be8547d296e
ZDo4HN/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
ZDo4HN/translation: 736332f2e4488609e42d2be8547d296e
@@ -504,8 +526,9 @@ checksums:
dEgA5A/origin/9/0: 0627e0040ca4939c9a57349e98c51797
dEgA5A/origin/10/0: 15792234f408027822ee71ccddde6f9b
dEgA5A/origin/11/0: 5c60161e173f5eae9276d8e4c70b4ebb
dEgA5A/origin/12/0: 83911e3eacbad4583e2b1647a784d154
dEgA5A/origin/13/0: dbad0c8d7863cb41f5495264f82e7081
dEgA5A/origin/12/0: a1fde3fcce608870830f168f027727ff
dEgA5A/origin/13/0: 83911e3eacbad4583e2b1647a784d154
dEgA5A/origin/14/0: dbad0c8d7863cb41f5495264f82e7081
dEgA5A/translation: 2e2a849c2223911717de8caa2c71bade
kryGs%2B/message: bba0beaced7ea954ceb980f2b022ffee
kryGs%2B/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
@@ -527,6 +550,9 @@ checksums:
VhMDMg/origin/2/0: 0627e0040ca4939c9a57349e98c51797
VhMDMg/origin/3/0: 0627e0040ca4939c9a57349e98c51797
VhMDMg/translation: a552fc5c4189ebc3e2e6018edda7d18f
fzGyWs/translation: 25dbbca7a83f6af119179e7a56c953d4
fzGyWs/message: 25dbbca7a83f6af119179e7a56c953d4
fzGyWs/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
yD3ZSw/message: 293d49fc3c75e9c425b64bd7126e6b46
yD3ZSw/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
yD3ZSw/translation: 293d49fc3c75e9c425b64bd7126e6b46
@@ -609,6 +635,9 @@ checksums:
tOZp9v/message: 3e67f6c507a47ec6e6dd232a34c166ad
tOZp9v/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
tOZp9v/translation: 3e67f6c507a47ec6e6dd232a34c166ad
t0dTPm/message: 95ec8431a14c39ac2ab308bef3297a53
t0dTPm/origin/0/0: 566b5982085ae649b2be0c614f666591
t0dTPm/translation: 95ec8431a14c39ac2ab308bef3297a53
"%2Ff3t6v/message": 0a46a8a30c6c0ebdcd01e72a7d64ec64
"%2Ff3t6v/origin/0/0": 0dccc39d0debbf3e089cb6d292de762b
"%2Ff3t6v/translation": 0a46a8a30c6c0ebdcd01e72a7d64ec64
@@ -618,13 +647,21 @@ checksums:
479pdJ/message: a0d2935d7b63f8dd19d7c0de47524416
479pdJ/origin/0/0: 0627e0040ca4939c9a57349e98c51797
479pdJ/translation: a0d2935d7b63f8dd19d7c0de47524416
iSLIjg/message: 8778ee245078a8be4a2ce855c8c56edc
iSLIjg/origin/0/0: 3c57a918258af4b44c187f58d203345f
iSLIjg/translation: 8778ee245078a8be4a2ce855c8c56edc
3jod3l/message: 71ca612e433a9ed61ccfbfe1cc87c78b
3jod3l/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
3jod3l/translation: 71ca612e433a9ed61ccfbfe1cc87c78b
nk7caK/message: 4440a0b9e387ef7136e3958e7a089213
nk7caK/origin/0/0: 3c57a918258af4b44c187f58d203345f
nk7caK/origin/1/0: 9d4260644f1a12f41d6b72574eeb111c
nk7caK/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
nk7caK/translation: 4440a0b9e387ef7136e3958e7a089213
sDLCvT/message: 033c5dcdb059ccb634aef7fe63f2f45d
sDLCvT/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
sDLCvT/translation: 033c5dcdb059ccb634aef7fe63f2f45d
MCIXdZ/message: ad3738e5eeabcebd3478cdc2545f543f
MCIXdZ/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
MCIXdZ/translation: ad3738e5eeabcebd3478cdc2545f543f
5eZwRW/message: 84a510a7485f43e43f2191bd161171d7
5eZwRW/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
5eZwRW/translation: 84a510a7485f43e43f2191bd161171d7
@@ -711,10 +748,16 @@ checksums:
A5hiCy/message: 57bfe6b58c42958a90f67d605a69ecdc
A5hiCy/origin/0/0: 3161dd30737e7d849d4ccc30a28bada9
A5hiCy/translation: 57bfe6b58c42958a90f67d605a69ecdc
SiPp29/message: 04816e0980ed5debb74fb92169766590
SiPp29/origin/0/0: f64cd62b620f454998523af2a4330278
SiPp29/translation: 04816e0980ed5debb74fb92169766590
FdtSNC/message: 2e6718e79964ea5ce22d76c2189c77ca
FdtSNC/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
FdtSNC/origin/1/0: 33acddc2fe10746463583d03146e7956
FdtSNC/translation: 2e6718e79964ea5ce22d76c2189c77ca
d%2BF6q9/message: 6045b4ff97fb7dca19ebadfa2e34a700
d%2BF6q9/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
d%2BF6q9/translation: 6045b4ff97fb7dca19ebadfa2e34a700
f8lDFq/message: 605475f5aaeb4dbccbf7c4eb9107b43d
f8lDFq/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
f8lDFq/translation: 605475f5aaeb4dbccbf7c4eb9107b43d
@@ -783,6 +826,8 @@ checksums:
cnGeoo/origin/4/0: 2a382c1cdf010f01a8e95c3e05f6df53
cnGeoo/origin/5/0: d55f532bb5ae247e3a018978be0da463
cnGeoo/origin/6/0: 4b4f55c033add8db69178563832227c6
cnGeoo/origin/7/0: a1fde3fcce608870830f168f027727ff
cnGeoo/origin/8/0: dc1a240d2d0cccb102c67ca311d4e457
cnGeoo/translation: 8bcf303dd10a645b5baacb02b47d72c9
ZDGm40/message: a9d11113f1a1d7e20582bdcc9633bcef
ZDGm40/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
@@ -804,6 +849,9 @@ checksums:
DFjdv0/message: bdf3e8342f581d498b7238cdd4897a4b
DFjdv0/origin/0/0: 0ad2af981579a62b084b3d41462c5546
DFjdv0/translation: bdf3e8342f581d498b7238cdd4897a4b
snMaH4/message: f15f64622d956beb69dce8ec24f237ae
snMaH4/origin/0/0: a1fde3fcce608870830f168f027727ff
snMaH4/translation: f15f64622d956beb69dce8ec24f237ae
kYu0eF/message: 9017c821209528d9b20d40a488854cdf
kYu0eF/origin/0/0: 83911e3eacbad4583e2b1647a784d154
kYu0eF/origin/1/0: 5f6c4a0c9be5098099167871ae1564af
@@ -826,6 +874,9 @@ checksums:
f8fH8W/message: 991b75727b6784c1a063a7462b76186d
f8fH8W/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
f8fH8W/translation: 991b75727b6784c1a063a7462b76186d
Odv3J6/message: f3cc49ba2dc3f9c33917f8a749d68bf5
Odv3J6/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
Odv3J6/translation: f3cc49ba2dc3f9c33917f8a749d68bf5
YBBifR/message: a2dd737d133887d34504728c7e7f05e3
YBBifR/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
YBBifR/translation: a2dd737d133887d34504728c7e7f05e3
@@ -897,6 +948,7 @@ checksums:
zxKs%2By/translation: 0b6c8ad7aba0873b7e212d5f1949ca97
ePK91l/message: eee7f39ff90b18852afc1671f21fbaa9
ePK91l/origin/0/0: b697a20a9536be4dd8ecd143461eafc7
ePK91l/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
ePK91l/translation: eee7f39ff90b18852afc1671f21fbaa9
tIdipJ/message: d8276dfc0189f371ec7d80a2047c07aa
tIdipJ/origin/0/0: 0ad2af981579a62b084b3d41462c5546
@@ -912,6 +964,9 @@ checksums:
TCOMOO/origin/0/0: afee530613110e248ffde6b56872ebed
TCOMOO/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
TCOMOO/translation: 244558dd716491b7ed72ba8ab73aa28f
fW5sSv/message: 145e9993fc334ea0718882c177928c01
fW5sSv/origin/0/0: f64cd62b620f454998523af2a4330278
fW5sSv/translation: 145e9993fc334ea0718882c177928c01
klpSmb/message: bbae5f2f8a442947d33099979bbbe899
klpSmb/origin/0/0: 2e937f7560901c3de71a592b4fd80f34
klpSmb/translation: bbae5f2f8a442947d33099979bbbe899
@@ -945,6 +1000,9 @@ checksums:
V8YWP3/message: f002074db0bd51d4f28d2736e140370a
V8YWP3/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
V8YWP3/translation: f002074db0bd51d4f28d2736e140370a
rQRXo8/message: 35ee0f41d7713e4497a4237db9a20264
rQRXo8/origin/0/0: f64cd62b620f454998523af2a4330278
rQRXo8/translation: 35ee0f41d7713e4497a4237db9a20264
fR9laE/message: bfceabde4c0b6f2cb439015b76549651
fR9laE/origin/0/0: 0627e0040ca4939c9a57349e98c51797
fR9laE/translation: bfceabde4c0b6f2cb439015b76549651
@@ -975,6 +1033,9 @@ checksums:
55xJ%2FO/message: ebecb5c1b72ba4b063117241f5ba4f2d
55xJ%2FO/origin/0/0: 0627e0040ca4939c9a57349e98c51797
55xJ%2FO/translation: ebecb5c1b72ba4b063117241f5ba4f2d
yhLUU8/message: e879ffdeccbe5b00fa7ece09114d149f
yhLUU8/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
yhLUU8/translation: e879ffdeccbe5b00fa7ece09114d149f
cji2nM/message: cbedc3f3213dfc4fdc8b7503ae1a5cd6
cji2nM/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
cji2nM/translation: cbedc3f3213dfc4fdc8b7503ae1a5cd6
@@ -990,6 +1051,9 @@ checksums:
9s9O5h/message: 0aec9bd8170bc84f5ea5c9a47c52ed26
9s9O5h/origin/0/0: 83911e3eacbad4583e2b1647a784d154
9s9O5h/translation: 0aec9bd8170bc84f5ea5c9a47c52ed26
gQ%2F02p/message: 08e8dfeafa38796abcdcc258917428d6
gQ%2F02p/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
gQ%2F02p/translation: 08e8dfeafa38796abcdcc258917428d6
lKAvEd/message: a8766853864eddc480113778a708871b
lKAvEd/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
lKAvEd/translation: a8766853864eddc480113778a708871b
@@ -1020,6 +1084,10 @@ checksums:
CYWHT%2B/origin/0/0: 03c00909b41be01231cd3e843c8ee44e
CYWHT%2B/origin/1/0: 03c00909b41be01231cd3e843c8ee44e
CYWHT%2B/translation: 0ba02753a797a74c6ea7b622c9f69c71
tst44n/message: d17179a947e9687dd2338ca524b26b63
tst44n/origin/0/0: f64cd62b620f454998523af2a4330278
tst44n/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
tst44n/translation: d17179a947e9687dd2338ca524b26b63
8iP0S8/message: 62b44c4973b92b806c69a4b15e0256dc
8iP0S8/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
8iP0S8/translation: 62b44c4973b92b806c69a4b15e0256dc
@@ -1042,6 +1110,12 @@ checksums:
53QYh8/origin/0/0: bcd95e286f12800a3e7ee0a71feb3ca5
53QYh8/origin/1/0: bcd95e286f12800a3e7ee0a71feb3ca5
53QYh8/translation: a746e2afe881c3bf0a8e82a931ca3495
o4sQAg/message: 97fd378ebf70814753414b035fe5639e
o4sQAg/origin/0/0: f64cd62b620f454998523af2a4330278
o4sQAg/translation: 97fd378ebf70814753414b035fe5639e
9YPBHv/message: 87429c06e5895397f325c4087378050a
9YPBHv/origin/0/0: a1fde3fcce608870830f168f027727ff
9YPBHv/translation: 87429c06e5895397f325c4087378050a
W8oT%2BL/message: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
W8oT%2BL/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
W8oT%2BL/translation: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
@@ -1049,6 +1123,13 @@ checksums:
YtUfwW/placeholders/0/0: 8085cc5670186c803eac6c957d57ea21
YtUfwW/origin/0/0: d96af8a06c2e38a72238bcd16e426859
YtUfwW/translation: 669a4b4247a73f53fb9b8b16e42d166f
5ntVtp/message: d3052fcc32ff4f12415ea6687a97303e
5ntVtp/origin/0/0: f64cd62b620f454998523af2a4330278
5ntVtp/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
5ntVtp/translation: d3052fcc32ff4f12415ea6687a97303e
R41XLH/message: 65f12e9667e1a3c968541fe96ffbf7c7
R41XLH/origin/0/0: f64cd62b620f454998523af2a4330278
R41XLH/translation: 65f12e9667e1a3c968541fe96ffbf7c7
ZL1A%2Bp/message: f8a50d1c8491404f73d3cf701e11f297
ZL1A%2Bp/origin/0/0: 12c1fab543c8848bcb10107d47336736
ZL1A%2Bp/translation: f8a50d1c8491404f73d3cf701e11f297
@@ -1089,6 +1170,9 @@ checksums:
l31EoQ/message: b4c05c9174637f4da2a41ead0d65bc01
l31EoQ/origin/0/0: 4980d8e27a628e7cd9a70b839d844083
l31EoQ/translation: b4c05c9174637f4da2a41ead0d65bc01
q3il6U/translation: 79a4fffd70bfbedb6174ea4c767f4ab9
q3il6U/message: 79a4fffd70bfbedb6174ea4c767f4ab9
q3il6U/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
"%2B3TYXa/message": 9fa1c942feb40d6350322b71561df789
"%2B3TYXa/origin/0/0": 4980d8e27a628e7cd9a70b839d844083
"%2B3TYXa/translation": 9fa1c942feb40d6350322b71561df789
@@ -1155,7 +1239,14 @@ checksums:
7hktsm/translation: 8e5e7bd026b5bec46bbfdce02ab9e0b8
RkXlPZ/message: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
RkXlPZ/origin/0/0: 98e2302d0d8d12ce65c4140b87450673
RkXlPZ/origin/1/0: 9d4260644f1a12f41d6b72574eeb111c
RkXlPZ/translation: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
PVT7q7/message: 8fa30040ec891db0b5b7d893786514ab
PVT7q7/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
PVT7q7/translation: 8fa30040ec891db0b5b7d893786514ab
"%2FbBVaD/message": 79b9a6153d695ff0175aa804b1c0286b
"%2FbBVaD/origin/0/0": 9d4260644f1a12f41d6b72574eeb111c
"%2FbBVaD/translation": 79b9a6153d695ff0175aa804b1c0286b
7eMo%2BU/message: 6251589da1964d55afabdfd64c84c335
7eMo%2BU/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
7eMo%2BU/translation: 6251589da1964d55afabdfd64c84c335
@@ -1165,7 +1256,11 @@ checksums:
UveK6V/translation: 896d0441384dcd2bfb3b23d61ff1944d
mgXgHu/message: dc1362de207d67fe84fb0caf60262e73
mgXgHu/origin/0/0: 94d3e20c29bb517dd394711d7524e478
mgXgHu/origin/1/0: 01b2fdc8e77a462c0ab211caecec400f
mgXgHu/translation: dc1362de207d67fe84fb0caf60262e73
XEoGkQ/message: 1cdeef0fc0f3ab18a88f34a557032582
XEoGkQ/origin/0/0: 01b2fdc8e77a462c0ab211caecec400f
XEoGkQ/translation: 1cdeef0fc0f3ab18a88f34a557032582
KAsRdK/message: 445f4efbc4b1e7509f4fd79ebfbb1476
KAsRdK/origin/0/0: 94d3e20c29bb517dd394711d7524e478
KAsRdK/translation: 445f4efbc4b1e7509f4fd79ebfbb1476
@@ -1192,6 +1287,9 @@ checksums:
XXbgHS/message: e5a9b1bd409b007141fe3d7890022f9a
XXbgHS/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
XXbgHS/translation: e5a9b1bd409b007141fe3d7890022f9a
SRvxId/message: e356e36ab5a3e30e81f132dd9abb5483
SRvxId/origin/0/0: f64cd62b620f454998523af2a4330278
SRvxId/translation: e356e36ab5a3e30e81f132dd9abb5483
LrcnbT/message: 8e7ae0783d60ef4624d3caf9bfc3747f
LrcnbT/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
LrcnbT/translation: 8e7ae0783d60ef4624d3caf9bfc3747f
@@ -1228,9 +1326,11 @@ checksums:
l3s5ri/translation: 348b8ab981de5b7f1fca6d7302263bbd
2MPcep/message: 7e749df6f068b9cdf1055ea38c9a80af
2MPcep/origin/0/0: 3c57a918258af4b44c187f58d203345f
2MPcep/origin/1/0: 3c57a918258af4b44c187f58d203345f
2MPcep/translation: 7e749df6f068b9cdf1055ea38c9a80af
BEVzjL/message: bcec1f271c82ddffc91ebc22021b03cb
BEVzjL/origin/0/0: 3c57a918258af4b44c187f58d203345f
BEVzjL/origin/1/0: 3c57a918258af4b44c187f58d203345f
BEVzjL/translation: bcec1f271c82ddffc91ebc22021b03cb
rRM7r0/message: d66d13649f81342f8b3a4f3b54370c20
rRM7r0/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
@@ -1257,6 +1357,9 @@ checksums:
WbTDwg/origin/3/0: eb63312c63f6c8d2a5b6520c89012123
WbTDwg/origin/4/0: eb63312c63f6c8d2a5b6520c89012123
WbTDwg/translation: 3de9afebcb9d4ce8ac42e14995f79ffd
NoNwIX/message: 239e3b722db75bbafc6c628a2685eed2
NoNwIX/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
NoNwIX/translation: 239e3b722db75bbafc6c628a2685eed2
6mbe4b/message: 83bfc81f20e60556c44ec80c7d0603ec
6mbe4b/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
6mbe4b/translation: 83bfc81f20e60556c44ec80c7d0603ec
@@ -1336,6 +1439,9 @@ checksums:
vXIe7J/message: 277fd1a41cc237a437cd1d5e4a80463b
vXIe7J/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
vXIe7J/translation: 277fd1a41cc237a437cd1d5e4a80463b
k7rCa%2F/translation: 890b7851af05f97ac53644e4154c13b4
k7rCa%2F/message: 890b7851af05f97ac53644e4154c13b4
k7rCa%2F/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
8Is1ih/message: 624592ba2ecf647a3fe60d493c1ee71a
8Is1ih/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
8Is1ih/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
@@ -1417,6 +1523,9 @@ checksums:
jl3aEJ/placeholders/0/0: 06f23520ebabfffed4999b5bcf61c9cd
jl3aEJ/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
jl3aEJ/translation: 4c38799ff25321ea25017bf4cd8e2e4f
agPptk/translation: 43983f2178fb28aacaef6c5f41282c4a
agPptk/message: 43983f2178fb28aacaef6c5f41282c4a
agPptk/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
W%2FElkg/message: 1f527cd6d1ed602930bcaa303f503b51
W%2FElkg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
W%2FElkg/translation: 1f527cd6d1ed602930bcaa303f503b51
@@ -1440,6 +1549,9 @@ checksums:
"%2FbZzdR/message": 40ea106c56d79634e058e087ad322025
"%2FbZzdR/origin/0/0": c0b59968b2b234167c488a05cc38710b
"%2FbZzdR/translation": 40ea106c56d79634e058e087ad322025
hty0d5/message: b4b1c8396e445e8b7956c747da2cf601
hty0d5/origin/0/0: 04c86ea96f180745739d59e822da4026
hty0d5/translation: b4b1c8396e445e8b7956c747da2cf601
"%2B8Nek%2F/message": 818f1192e32bb855597f930d3e78806e
"%2B8Nek%2F/origin/0/0": 9737a9cef0646ff4bbd942b1924b9a2c
"%2B8Nek%2F/origin/1/0": 82a5cf8d69f098641eb357bf102cd504
@@ -1455,10 +1567,15 @@ checksums:
T7LJic/message: 2e8c41bfafb42fa299ce56e4300205ff
T7LJic/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
T7LJic/translation: 2e8c41bfafb42fa299ce56e4300205ff
uEGGDs/message: 844d23d8391d57110f8b03a14a35cf00
uEGGDs/origin/0/0: f64cd62b620f454998523af2a4330278
uEGGDs/translation: 844d23d8391d57110f8b03a14a35cf00
6YtxFj/message: 9368b5a047572b6051f334af5aa76819
6YtxFj/origin/0/0: d40778923d288b8a81f4cabac4898d43
6YtxFj/origin/1/0: 3161dd30737e7d849d4ccc30a28bada9
6YtxFj/origin/2/0: bcd95e286f12800a3e7ee0a71feb3ca5
6YtxFj/origin/3/0: f64cd62b620f454998523af2a4330278
6YtxFj/origin/4/0: dc1a240d2d0cccb102c67ca311d4e457
6YtxFj/translation: 9368b5a047572b6051f334af5aa76819
UxKoFf/message: 0373afd8238db1c49f4be4fa6cdf5cd3
UxKoFf/origin/0/0: feacdaf2791aaddec128f463ffa2e807
@@ -1505,6 +1622,9 @@ checksums:
RJA%2Bsg/message: 665ff16ec736cba20e8a1b3beb3baa09
RJA%2Bsg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
RJA%2Bsg/translation: 665ff16ec736cba20e8a1b3beb3baa09
sbNNyi/message: a9f91ae6676523cbda6e7373d97eaee5
sbNNyi/origin/0/0: f64cd62b620f454998523af2a4330278
sbNNyi/translation: a9f91ae6676523cbda6e7373d97eaee5
curoK5/message: 8eecf05b4ce29aa880adfed2e11476af
curoK5/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
curoK5/translation: 8eecf05b4ce29aa880adfed2e11476af
@@ -1518,9 +1638,9 @@ checksums:
"%2FglL9Q/placeholders/0/0": 07bd981d42ffe27ee81d1dffa7dc0d43
"%2FglL9Q/origin/0/0": f78c0a7870158f5dab5216bcd6092e49
"%2FglL9Q/translation": a7a95e1dfdb4396da9e2be0e27f42323
tlhgDC/translation: b96a6f8fa3267ec01e68e61656382b2e
tlhgDC/message: b96a6f8fa3267ec01e68e61656382b2e
tlhgDC/origin/0/0: f78c0a7870158f5dab5216bcd6092e49
tlhgDC/translation: b96a6f8fa3267ec01e68e61656382b2e
Eg99Sc/message: f718c896810da3612202887f9a4374fa
Eg99Sc/origin/0/0: d96af8a06c2e38a72238bcd16e426859
Eg99Sc/translation: f718c896810da3612202887f9a4374fa
@@ -1545,6 +1665,9 @@ checksums:
fvLNDy/message: f18ee3d7230cc33b68bd17b429d1d442
fvLNDy/origin/0/0: fc0c18a095a68277a0f90a115942683b
fvLNDy/translation: f18ee3d7230cc33b68bd17b429d1d442
i30J2U/message: 8b7ec59963fb26e13948600c55c8de1d
i30J2U/origin/0/0: 3c57a918258af4b44c187f58d203345f
i30J2U/translation: 8b7ec59963fb26e13948600c55c8de1d
ERpq2P/message: 5db6294712528cd897b15ae36f4fd834
ERpq2P/placeholders/debouncedQuery/0: 8471823afb4210cf564ca0d8bef9dd66
ERpq2P/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
@@ -1552,6 +1675,9 @@ checksums:
Q9pQaX/message: 2eb502333aaf6c58e8ab23d881e2e357
Q9pQaX/origin/0/0: 7f5f72488c743ec0795cf10058ce3351
Q9pQaX/translation: 2eb502333aaf6c58e8ab23d881e2e357
TX0bT7/message: a7afbc2487022f17b6d993f556fd2735
TX0bT7/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
TX0bT7/translation: a7afbc2487022f17b6d993f556fd2735
9h7RDh/message: 82b4e0c9a3f5b4bd93590847de7c32a1
9h7RDh/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
9h7RDh/translation: 82b4e0c9a3f5b4bd93590847de7c32a1
@@ -1601,6 +1727,9 @@ checksums:
SPLN9O/message: cc2178dac4bdf6b07f030cfc2a7510e6
SPLN9O/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
SPLN9O/translation: cc2178dac4bdf6b07f030cfc2a7510e6
8F1i42/message: 97612e6230bc7a1ebd99380bf561b732
8F1i42/origin/0/0: 01b2fdc8e77a462c0ab211caecec400f
8F1i42/translation: 97612e6230bc7a1ebd99380bf561b732
Uworke/message: 213d63da450f35dabb3ab0e35e29feed
Uworke/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
Uworke/translation: 213d63da450f35dabb3ab0e35e29feed
@@ -1679,6 +1808,9 @@ checksums:
7b2yuC/message: 4b32c17e19b79bcbf0bb092c06ba310f
7b2yuC/origin/0/0: d96af8a06c2e38a72238bcd16e426859
7b2yuC/translation: 4b32c17e19b79bcbf0bb092c06ba310f
jEw0Mr/message: e3bcfb605be4ee32aa19d9ac32bb11a4
jEw0Mr/origin/0/0: f64cd62b620f454998523af2a4330278
jEw0Mr/translation: e3bcfb605be4ee32aa19d9ac32bb11a4
tmlJN1/message: c16c69c3b742b1e19148378d50adf37f
tmlJN1/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
tmlJN1/origin/1/0: a50c4fbde8da6e0a77c4e0aef0b44dea
@@ -1700,40 +1832,41 @@ checksums:
tyHiOa/origin/10/0: fc0c18a095a68277a0f90a115942683b
tyHiOa/origin/11/0: fc0c18a095a68277a0f90a115942683b
tyHiOa/origin/12/0: 3c57a918258af4b44c187f58d203345f
tyHiOa/origin/13/0: 3a89a46ac678e34dc8eb687b07a4ad4b
tyHiOa/origin/14/0: a31bf5b0f5348dedcad98939fcf7f93f
tyHiOa/origin/13/0: 3c57a918258af4b44c187f58d203345f
tyHiOa/origin/14/0: 3a89a46ac678e34dc8eb687b07a4ad4b
tyHiOa/origin/15/0: a31bf5b0f5348dedcad98939fcf7f93f
tyHiOa/origin/16/0: 6e60e97757a446204985b5c4374ed66d
tyHiOa/origin/17/0: d2cacb98e99d5163eff64f9c4c9cbb47
tyHiOa/origin/18/0: b2059fa4b8b99cc10440da26475b234c
tyHiOa/origin/19/0: 2a382c1cdf010f01a8e95c3e05f6df53
tyHiOa/origin/20/0: d55f532bb5ae247e3a018978be0da463
tyHiOa/origin/21/0: 4b4f55c033add8db69178563832227c6
tyHiOa/origin/22/0: 2201bf679c663a732d7f1b3c3ce3897f
tyHiOa/origin/23/0: 27ce4b16f6ada6ca35ac8d1adff329c5
tyHiOa/origin/24/0: a55c169a847bdde3b119eeb97e202c30
tyHiOa/origin/25/0: faa852a0c0e3c81fe948e394029c1ba5
tyHiOa/origin/26/0: a2dd1fc876bd42b9db7ca7aaa57ac5e2
tyHiOa/origin/27/0: e59eec882722e98169cc27f545facfe7
tyHiOa/origin/28/0: b1f4eb6768222ea825703105f6014486
tyHiOa/origin/29/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
tyHiOa/origin/16/0: a31bf5b0f5348dedcad98939fcf7f93f
tyHiOa/origin/17/0: 6e60e97757a446204985b5c4374ed66d
tyHiOa/origin/18/0: d2cacb98e99d5163eff64f9c4c9cbb47
tyHiOa/origin/19/0: b2059fa4b8b99cc10440da26475b234c
tyHiOa/origin/20/0: 2a382c1cdf010f01a8e95c3e05f6df53
tyHiOa/origin/21/0: d55f532bb5ae247e3a018978be0da463
tyHiOa/origin/22/0: 4b4f55c033add8db69178563832227c6
tyHiOa/origin/23/0: 2201bf679c663a732d7f1b3c3ce3897f
tyHiOa/origin/24/0: 27ce4b16f6ada6ca35ac8d1adff329c5
tyHiOa/origin/25/0: a55c169a847bdde3b119eeb97e202c30
tyHiOa/origin/26/0: faa852a0c0e3c81fe948e394029c1ba5
tyHiOa/origin/27/0: a2dd1fc876bd42b9db7ca7aaa57ac5e2
tyHiOa/origin/28/0: e59eec882722e98169cc27f545facfe7
tyHiOa/origin/29/0: b1f4eb6768222ea825703105f6014486
tyHiOa/origin/30/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
tyHiOa/origin/31/0: 68450df287fb46ea629e818732c1291a
tyHiOa/origin/32/0: afee530613110e248ffde6b56872ebed
tyHiOa/origin/31/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
tyHiOa/origin/32/0: 68450df287fb46ea629e818732c1291a
tyHiOa/origin/33/0: afee530613110e248ffde6b56872ebed
tyHiOa/origin/34/0: afee530613110e248ffde6b56872ebed
tyHiOa/origin/35/0: 8628ddb6f4ded25c0f17e2cbda8207d3
tyHiOa/origin/35/0: afee530613110e248ffde6b56872ebed
tyHiOa/origin/36/0: 8628ddb6f4ded25c0f17e2cbda8207d3
tyHiOa/origin/37/0: 7493592314bc3b96ad96127a949ba1ba
tyHiOa/origin/38/0: 03c00909b41be01231cd3e843c8ee44e
tyHiOa/origin/39/0: 5c60161e173f5eae9276d8e4c70b4ebb
tyHiOa/origin/40/0: 83911e3eacbad4583e2b1647a784d154
tyHiOa/origin/41/0: d465da67c0a870d198eb7f313569c137
tyHiOa/origin/42/0: 33bf266228f835e12c0bfb0e5efb3be9
tyHiOa/origin/43/0: ff48991e314b0b3e989a4dac59860fd5
tyHiOa/origin/44/0: 30791e26a172d47d3cb0693e82679246
tyHiOa/origin/45/0: dbad0c8d7863cb41f5495264f82e7081
tyHiOa/origin/46/0: 0dccc39d0debbf3e089cb6d292de762b
tyHiOa/origin/37/0: 8628ddb6f4ded25c0f17e2cbda8207d3
tyHiOa/origin/38/0: 7493592314bc3b96ad96127a949ba1ba
tyHiOa/origin/39/0: 03c00909b41be01231cd3e843c8ee44e
tyHiOa/origin/40/0: 5c60161e173f5eae9276d8e4c70b4ebb
tyHiOa/origin/41/0: 83911e3eacbad4583e2b1647a784d154
tyHiOa/origin/42/0: d465da67c0a870d198eb7f313569c137
tyHiOa/origin/43/0: 33bf266228f835e12c0bfb0e5efb3be9
tyHiOa/origin/44/0: ff48991e314b0b3e989a4dac59860fd5
tyHiOa/origin/45/0: 30791e26a172d47d3cb0693e82679246
tyHiOa/origin/46/0: dbad0c8d7863cb41f5495264f82e7081
tyHiOa/origin/47/0: 0dccc39d0debbf3e089cb6d292de762b
tyHiOa/translation: 21ffcf0b00e7cd7b64f7454a95762e1d
PZCqeW/message: 325dea6dd0348a27a6818db2c1340c98
PZCqeW/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
@@ -1892,11 +2025,17 @@ checksums:
YEOVrT/message: 86b76024524fc585b2c3950126ef6f62
YEOVrT/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
YEOVrT/translation: 86b76024524fc585b2c3950126ef6f62
"%2B5kO8P/message": 77b65adf8ae2b69eaed51b6104ded65e
"%2B5kO8P/origin/0/0": 04c86ea96f180745739d59e822da4026
"%2B5kO8P/translation": 77b65adf8ae2b69eaed51b6104ded65e
tfDRzk/message: f7a2929f33bc420195e59ac5a8bcd454
tfDRzk/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
tfDRzk/origin/1/0: b2059fa4b8b99cc10440da26475b234c
tfDRzk/origin/2/0: 03c00909b41be01231cd3e843c8ee44e
tfDRzk/translation: f7a2929f33bc420195e59ac5a8bcd454
y3aU20/message: 53dd9f4f0a4accc822fa5c1f2f6d118a
y3aU20/origin/0/0: f64cd62b620f454998523af2a4330278
y3aU20/translation: 53dd9f4f0a4accc822fa5c1f2f6d118a
MdwUGG/message: d0f2d7d0fd682ceaf4ca12c6353fd75a
MdwUGG/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
MdwUGG/translation: d0f2d7d0fd682ceaf4ca12c6353fd75a
@@ -1906,6 +2045,12 @@ checksums:
SkCNhl/message: 137017b2b3e885c4894ca8c899af5bc2
SkCNhl/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
SkCNhl/translation: 137017b2b3e885c4894ca8c899af5bc2
e1v%2BJ3/message: 9aeca4f286e14966ef1c32d2abc74584
e1v%2BJ3/origin/0/0: f64cd62b620f454998523af2a4330278
e1v%2BJ3/translation: 9aeca4f286e14966ef1c32d2abc74584
OkTY4%2B/message: 5deb2ff7c984db2d69457d24c3dd7d0a
OkTY4%2B/origin/0/0: f64cd62b620f454998523af2a4330278
OkTY4%2B/translation: 5deb2ff7c984db2d69457d24c3dd7d0a
a3LDKx/message: 4b34923fef858a2b9a4a914c3e822889
a3LDKx/origin/0/0: c0b59968b2b234167c488a05cc38710b
a3LDKx/translation: 4b34923fef858a2b9a4a914c3e822889
@@ -1914,7 +2059,11 @@ checksums:
K3iLeO/translation: e3bf6f95419169f460fb6918a0afc512
wgNoIs/message: eedc7cdb02de467c15dc418a066a77f2
wgNoIs/origin/0/0: 3c57a918258af4b44c187f58d203345f
wgNoIs/origin/1/0: 3c57a918258af4b44c187f58d203345f
wgNoIs/translation: eedc7cdb02de467c15dc418a066a77f2
o4e%2F70/message: d39f10456ad4ef683c1d0161e9b87521
o4e%2F70/origin/0/0: f64cd62b620f454998523af2a4330278
o4e%2F70/translation: d39f10456ad4ef683c1d0161e9b87521
rYUZIe/message: c7b0ec447415ff62856803a4d7838bd6
rYUZIe/origin/0/0: 3c57a918258af4b44c187f58d203345f
rYUZIe/translation: c7b0ec447415ff62856803a4d7838bd6
@@ -1930,6 +2079,9 @@ checksums:
RoafuO/message: 9631cc08d49da04475b30a0d320ce97c
RoafuO/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
RoafuO/translation: 9631cc08d49da04475b30a0d320ce97c
eus61c/message: fb230b27a8a91d7e597d6579466246b8
eus61c/origin/0/0: f64cd62b620f454998523af2a4330278
eus61c/translation: fb230b27a8a91d7e597d6579466246b8
v3YoMA/message: 3fff865dc00435f82896fc302ea45630
v3YoMA/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
v3YoMA/translation: 3fff865dc00435f82896fc302ea45630
@@ -1958,6 +2110,9 @@ checksums:
F%2FFPk%2F/message: 8aa60ed978b9f45a705d99dc1ee04f37
F%2FFPk%2F/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
F%2FFPk%2F/translation: 8aa60ed978b9f45a705d99dc1ee04f37
"%2Bh2faV/message": 402ab0ca17c732457498d09a83dbe8ce
"%2Bh2faV/origin/0/0": 566b5982085ae649b2be0c614f666591
"%2Bh2faV/translation": 402ab0ca17c732457498d09a83dbe8ce
Ci%2FKUX/message: 5d0bacf7ff696da940f232df45edfd39
Ci%2FKUX/origin/0/0: 5f6c4a0c9be5098099167871ae1564af
Ci%2FKUX/translation: 5d0bacf7ff696da940f232df45edfd39
@@ -1996,6 +2151,9 @@ checksums:
zEcnBA/message: 513c3b4d9f0fda60fe079d42dcbd3882
zEcnBA/origin/0/0: c0b59968b2b234167c488a05cc38710b
zEcnBA/translation: 513c3b4d9f0fda60fe079d42dcbd3882
4Revpc/translation: 3adb3ae7891c5736776df840cff2360b
4Revpc/message: 3adb3ae7891c5736776df840cff2360b
4Revpc/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
"%2B%2BrVAm/message": 2227508afb0d38a027e323207e47d4a2
"%2B%2BrVAm/origin/0/0": eb63312c63f6c8d2a5b6520c89012123
"%2B%2BrVAm/translation": 2227508afb0d38a027e323207e47d4a2
@@ -2015,6 +2173,9 @@ checksums:
Y8yzGg/message: 396a94648584067cc5795f7b32d3d663
Y8yzGg/origin/0/0: 82a5cf8d69f098641eb357bf102cd504
Y8yzGg/translation: 396a94648584067cc5795f7b32d3d663
uAQUqI/message: 4e1fcce15854d824919b4a582c697c90
uAQUqI/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
uAQUqI/translation: 4e1fcce15854d824919b4a582c697c90
WYDptz/message: 05f2b4abfc36def17756a6969983cf86
WYDptz/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
WYDptz/translation: 05f2b4abfc36def17756a6969983cf86
@@ -2022,6 +2183,9 @@ checksums:
zzDlyQ/origin/0/0: d96af8a06c2e38a72238bcd16e426859
zzDlyQ/origin/1/0: d96af8a06c2e38a72238bcd16e426859
zzDlyQ/translation: c43827becada6750f7a25890905f38b9
DBC3t5/message: d21934fb36dd8a82e36352968207d44e
DBC3t5/origin/0/0: 04c86ea96f180745739d59e822da4026
DBC3t5/translation: d21934fb36dd8a82e36352968207d44e
uZg2%2Bw/message: 21af8119141afcce5f12da489f34db6a
uZg2%2Bw/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
uZg2%2Bw/translation: 21af8119141afcce5f12da489f34db6a
@@ -2067,18 +2231,25 @@ checksums:
UCLeG0/message: 0b8a8b23aff021a6cde79613fdb39c28
UCLeG0/origin/0/0: 98e2302d0d8d12ce65c4140b87450673
UCLeG0/translation: 0b8a8b23aff021a6cde79613fdb39c28
NnH3pK/message: 82a46f197779af4dde92e7e5f9336ccf
NnH3pK/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
NnH3pK/translation: 82a46f197779af4dde92e7e5f9336ccf
itgYbC/message: efe14181bc517316e3ab3258d72d2ff9
itgYbC/origin/0/0: f64cd62b620f454998523af2a4330278
itgYbC/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
itgYbC/translation: efe14181bc517316e3ab3258d72d2ff9
VUZDlr/message: e958aa3d3743031e2cbfbfd8f1e381f6
VUZDlr/origin/0/0: 5e2ed4920ffd2f535ece848ff44900bd
VUZDlr/translation: e958aa3d3743031e2cbfbfd8f1e381f6
nhMhRr/message: 07edd8c50685a52c0969d711df26d768
nhMhRr/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
nhMhRr/translation: 07edd8c50685a52c0969d711df26d768
NcFrgC/translation: bd15a7b3d8b4bce18cd42682ea1ec164
NcFrgC/message: bd15a7b3d8b4bce18cd42682ea1ec164
NcFrgC/origin/0/0: 0ad2af981579a62b084b3d41462c5546
C6gv54/translation: 1573c77059bc92df17e661befda8d9b7
NcFrgC/translation: bd15a7b3d8b4bce18cd42682ea1ec164
C6gv54/message: 1573c77059bc92df17e661befda8d9b7
C6gv54/origin/0/0: 0ad2af981579a62b084b3d41462c5546
C6gv54/translation: 1573c77059bc92df17e661befda8d9b7
jlB2RY/message: 67a76bf346ab4b48563269e9d941a64a
jlB2RY/origin/0/0: 0627e0040ca4939c9a57349e98c51797
jlB2RY/translation: 67a76bf346ab4b48563269e9d941a64a
@@ -2095,6 +2266,9 @@ checksums:
gUX7b%2B/message: 692cb2e8a8e610953c996826beed45a2
gUX7b%2B/origin/0/0: 09a6a32f9a6b415e81b4d2f8181a0e6b
gUX7b%2B/translation: 692cb2e8a8e610953c996826beed45a2
0xD8Of/message: 65ad307ba26227fe44343f17e52138de
0xD8Of/origin/0/0: 01b2fdc8e77a462c0ab211caecec400f
0xD8Of/translation: 65ad307ba26227fe44343f17e52138de
fQCrjt/message: 970e17a115f7374e60e0a8ded425db8f
fQCrjt/placeholders/0/0: ed822bd0ada0a2e8d894cac37b70c346
fQCrjt/origin/0/0: d6920b20329a9efe5985a39a295d83e1
@@ -2155,6 +2329,9 @@ checksums:
4gAX8s/message: 29dea3e0b6238874f8c7a27619df8e36
4gAX8s/origin/0/0: 634473ef965836871efe40875a36b6ab
4gAX8s/translation: 29dea3e0b6238874f8c7a27619df8e36
L5WQLg/message: 38a978eab728978612d1dee8bdeb915b
L5WQLg/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
L5WQLg/translation: 38a978eab728978612d1dee8bdeb915b
eEQyz%2B/message: 0d3bac559c71ec4b8734f9f212320de5
eEQyz%2B/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
eEQyz%2B/translation: 0d3bac559c71ec4b8734f9f212320de5
@@ -2276,9 +2453,9 @@ checksums:
TFv5tM/message: 4f2240aeff6f7275feb33ca3f608e48c
TFv5tM/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
TFv5tM/translation: 4f2240aeff6f7275feb33ca3f608e48c
It4%2FFS/translation: ae1527c2765592f13980acb66997e570
It4%2FFS/message: ae1527c2765592f13980acb66997e570
It4%2FFS/origin/0/0: 0ad2af981579a62b084b3d41462c5546
It4%2FFS/translation: ae1527c2765592f13980acb66997e570
E8zYtd/message: b683cc07092348c1e75dba40b1262b85
E8zYtd/placeholders/0/0: 182720128698512d43d1bf43824cd7f8
E8zYtd/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
@@ -2374,10 +2551,17 @@ checksums:
BBUDfW/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
BBUDfW/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
BBUDfW/translation: e79afcd4398e52e10e7e8243a7eecabe
IagCbF/message: ca97457614226960d41dd18c3c29c86b
IagCbF/origin/0/0: f64cd62b620f454998523af2a4330278
IagCbF/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
IagCbF/translation: ca97457614226960d41dd18c3c29c86b
MI5OBT/message: 875d37d2d920f98ef60a314eda8a5d9a
MI5OBT/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
MI5OBT/origin/1/0: 30791e26a172d47d3cb0693e82679246
MI5OBT/translation: 875d37d2d920f98ef60a314eda8a5d9a
7aONWr/message: 96af2ad24e17a1fe98a79e0a140a1d11
7aONWr/origin/0/0: f64cd62b620f454998523af2a4330278
7aONWr/translation: 96af2ad24e17a1fe98a79e0a140a1d11
i5rE2%2F/message: 7f3c9ded3f8b8765809ac7dd9505c0b0
i5rE2%2F/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
i5rE2%2F/origin/1/0: 30791e26a172d47d3cb0693e82679246
@@ -2389,6 +2573,9 @@ checksums:
QNtP5M/message: 33f0ca0aa8f4b0de99aad267a61a094d
QNtP5M/origin/0/0: bcd95e286f12800a3e7ee0a71feb3ca5
QNtP5M/translation: 33f0ca0aa8f4b0de99aad267a61a094d
n6EWYz/message: dc868fbeff71dc275c8b0635c9f97a5a
n6EWYz/origin/0/0: f64cd62b620f454998523af2a4330278
n6EWYz/translation: dc868fbeff71dc275c8b0635c9f97a5a
7PzzBU/message: 61073457a5c3901084b557d065f876be
7PzzBU/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
7PzzBU/translation: 61073457a5c3901084b557d065f876be
@@ -2428,6 +2615,35 @@ checksums:
9y1RcT/message: 2dfa121e0ff1914e08068308a81973c0
9y1RcT/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
9y1RcT/translation: 2dfa121e0ff1914e08068308a81973c0
GdWB%2BV/message: 6a15a9b926ecd2c91454e9da19c6049e
GdWB%2BV/origin/0/0: f64cd62b620f454998523af2a4330278
GdWB%2BV/translation: 6a15a9b926ecd2c91454e9da19c6049e
2X4ecw/message: fcefd247ec76a372002d2cffac3c5b0f
2X4ecw/origin/0/0: a1fde3fcce608870830f168f027727ff
2X4ecw/translation: fcefd247ec76a372002d2cffac3c5b0f
jIuWsV/message: 003dddfd1d3ed7c94ec0027e5bb9d496
jIuWsV/origin/0/0: f64cd62b620f454998523af2a4330278
jIuWsV/translation: 003dddfd1d3ed7c94ec0027e5bb9d496
U2%2Brn0/message: b94fc9ad83b267fcccacd0f4c529148b
U2%2Brn0/origin/0/0: f64cd62b620f454998523af2a4330278
U2%2Brn0/translation: b94fc9ad83b267fcccacd0f4c529148b
8iP9oQ/message: 5c07446c838d07572c6b40621507e951
8iP9oQ/origin/0/0: f64cd62b620f454998523af2a4330278
8iP9oQ/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
8iP9oQ/translation: 5c07446c838d07572c6b40621507e951
3d54Wj/message: d67dc42810694fd1344a9a9545aca1e7
3d54Wj/origin/0/0: f64cd62b620f454998523af2a4330278
3d54Wj/translation: d67dc42810694fd1344a9a9545aca1e7
Hf1cEZ/message: 4b2d60dd7d86dafbf32155a5246a1d40
Hf1cEZ/origin/0/0: f64cd62b620f454998523af2a4330278
Hf1cEZ/translation: 4b2d60dd7d86dafbf32155a5246a1d40
v1kQyJ/message: 0329bc102d3ab3c73410d126e119de12
v1kQyJ/origin/0/0: 77c9f6139024b2069e4fdc5120f69cb5
v1kQyJ/origin/1/0: 566b5982085ae649b2be0c614f666591
v1kQyJ/translation: 0329bc102d3ab3c73410d126e119de12
xLTZVf/message: ce928c74255344905f5c3f02f44c991e
xLTZVf/origin/0/0: 5f6c4a0c9be5098099167871ae1564af
xLTZVf/translation: ce928c74255344905f5c3f02f44c991e
9eF5oV/message: 4928884739ba559e6e4b960e80fe1452
9eF5oV/origin/0/0: 4a1be3707d14c696a16f453d132fb248
9eF5oV/translation: 4928884739ba559e6e4b960e80fe1452
@@ -2571,12 +2787,24 @@ checksums:
tca56%2F/message: 19d84e934877efc0fef285ff3d6e7849
tca56%2F/origin/0/0: 12c1fab543c8848bcb10107d47336736
tca56%2F/translation: 19d84e934877efc0fef285ff3d6e7849
HcT8J4/message: 19478f9152e6879c9c029198e635b44d
HcT8J4/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
HcT8J4/translation: 19478f9152e6879c9c029198e635b44d
AdxYds/message: a265e2d3b01efd3fe04ac251d2ff6afc
AdxYds/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
AdxYds/translation: a265e2d3b01efd3fe04ac251d2ff6afc
PELbXB/message: 6984aed03bbcd94041c5dd20f62e56ed
PELbXB/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
PELbXB/translation: 6984aed03bbcd94041c5dd20f62e56ed
Ozt2vv/message: 8f730f68d52b7efa727aa0784282ebcb
Ozt2vv/origin/0/0: 0627e0040ca4939c9a57349e98c51797
Ozt2vv/translation: 8f730f68d52b7efa727aa0784282ebcb
"%2FtOdd2/message": 099e1780348cd7bf617721344371a430
"%2FtOdd2/origin/0/0": 03c00909b41be01231cd3e843c8ee44e
"%2FtOdd2/translation": 099e1780348cd7bf617721344371a430
"%2BjbXzb/message": c07e27183a52361fc05b919c2a3f5c1f
"%2BjbXzb/origin/0/0": 3c57a918258af4b44c187f58d203345f
"%2BjbXzb/translation": c07e27183a52361fc05b919c2a3f5c1f
CJWDTP/message: 126aeeb73f7e4cad338056e4c933f39b
CJWDTP/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
CJWDTP/translation: 126aeeb73f7e4cad338056e4c933f39b

View File

@@ -27,6 +27,7 @@
"@kan/api": "workspace:*",
"@kan/auth": "workspace:*",
"@kan/db": "workspace:^",
"@kan/logger": "workspace:^",
"@kan/shared": "workspace:^",
"@lingui/babel-preset-react": "^2.9.2",
"@lingui/conf": "^5.3.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -111,6 +111,7 @@ export function NewWorkspaceForm() {
slug: values.slug,
plan: values.plan,
role: "admin",
weekStartDay: 1,
});
// If in cloud and Pro toggle is enabled, create checkout session for pro

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createS3Client } from "@kan/shared/utils";
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
const MAX_SIZE_BYTES = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
export const config = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
import { Tooltip } from "~/components/Tooltip";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { useDragToScroll } from "~/hooks/useDragToScroll";
import { useScrollRestore } from "~/hooks/useScrollRestore";
import { usePermissions } from "~/hooks/usePermissions";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
@@ -124,11 +125,21 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
data: boardData,
isSuccess,
isLoading: isQueryLoading,
error,
} = api.board.byId.useQuery(queryParams, {
enabled: !!boardId,
placeholderData: keepPreviousData,
});
// Redirect to 404 if board doesn't exist
useEffect(() => {
if (router.isReady && boardId && !isQueryLoading) {
if (error?.data?.code === "NOT_FOUND" || (!boardData && !isQueryLoading)) {
router.replace("/404");
}
}
}, [router, boardId, isQueryLoading, error, boardData]);
const refetchBoard = async () => {
if (boardId) await utils.board.byId.refetch({ boardPublicId: boardId });
};
@@ -141,6 +152,8 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const isLoading = isInitialLoading || isQueryLoading;
useScrollRestore(boardId, scrollRef, router, !isLoading && (boardData?.lists.length ?? 0) > 0);
const updateListMutation = api.list.update.useMutation({
onMutate: async (args) => {
await utils.board.byId.cancel();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { env } from "next-runtime-env";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import { FontSizeSelector } from "~/components/FontSizeSelector";
import { LanguageSelector } from "~/components/LanguageSelector";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
@@ -54,6 +55,16 @@ export default function AccountSettings() {
<LanguageSelector />
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Font size`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Change the application font size.`}
</p>
<FontSizeSelector />
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Delete account`}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,11 @@ services:
- NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT}
- REDIS_URL=${REDIS_URL}
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
- LOG_LEVEL=${LOG_LEVEL}
- AXIOM_TOKEN=${AXIOM_TOKEN}
- AXIOM_DATASET=${AXIOM_DATASET}
# Stripe
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
@@ -66,6 +71,7 @@ services:
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}

View File

@@ -37,6 +37,9 @@ services:
# Redis (optional - for rate limiting)
- REDIS_URL=${REDIS_URL}
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
- LOG_LEVEL=${LOG_LEVEL}
# Admin API key (optional)
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
@@ -57,6 +60,7 @@ services:
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { z } from "zod";
import * as cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as checklistRepo from "@kan/db/repository/checklist.repo";
import { stripHtml } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
@@ -223,7 +224,7 @@ export const checklistRouter = createTRPCRouter({
.input(
z.object({
checklistPublicId: z.string().length(12),
title: z.string().min(1).max(500),
title: z.string().min(1).max(500).transform(stripHtml),
}),
)
.output(checklistItemSchema)
@@ -288,7 +289,7 @@ export const checklistRouter = createTRPCRouter({
.input(
z.object({
checklistItemPublicId: z.string().length(12),
title: z.string().min(1).max(500).optional(),
title: z.string().min(1).max(500).transform(stripHtml).optional(),
completed: z.boolean().optional(),
index: z.number().int().min(0).optional(),
}),
@@ -322,30 +323,29 @@ export const checklistRouter = createTRPCRouter({
const previousTitle = item.title;
let updatedItem;
let updatedItem;
if (input.title !== undefined || input.completed !== undefined) {
updatedItem = await checklistRepo.updateItemById(ctx.db, {
id: item.id,
title: input.title,
completed: input.completed,
});
}
if (input.title !== undefined || input.completed !== undefined) {
updatedItem = await checklistRepo.updateItemById(ctx.db, {
id: item.id,
title: input.title,
completed: input.completed,
});
}
if (input.index !== undefined) {
updatedItem = await checklistRepo.reorderItem(ctx.db, {
itemId: item.id,
newIndex: input.index,
});
}
if (!updatedItem) {
throw new TRPCError({
message: `Failed to update checklist item`,
code: "INTERNAL_SERVER_ERROR",
});
}
if (input.index !== undefined) {
updatedItem = await checklistRepo.reorderItem(ctx.db, {
itemId: item.id,
newIndex: input.index,
});
}
if (!updatedItem) {
throw new TRPCError({
message: `Failed to update checklist item`,
code: "INTERNAL_SERVER_ERROR",
});
}
// Log completion toggle
if (input.completed !== undefined) {
@@ -371,7 +371,6 @@ export const checklistRouter = createTRPCRouter({
}
return updatedItem;
}),
deleteItem: protectedProcedure
.meta({

View File

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

View File

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

View File

@@ -31,7 +31,6 @@ export const userRouter = createTRPCRouter({
.object({
id: z.number(),
prefix: z.string().nullable(),
key: z.string(),
})
.nullable(),
}),
@@ -62,7 +61,7 @@ export const userRouter = createTRPCRouter({
return {
...result,
image: imageUrl,
apiKey: apiKey ?? null,
apiKey: apiKey ? { id: apiKey.id, prefix: apiKey.prefix } : null,
};
}),
update: protectedProcedure

View File

@@ -0,0 +1,423 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
vi.mock("@kan/db/repository/webhook.repo", () => ({
getAllByWorkspaceId: vi.fn(),
getByPublicId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
hardDelete: vi.fn(),
}));
vi.mock("@kan/db/repository/workspace.repo", () => ({
getByPublicId: vi.fn(),
}));
vi.mock("../utils/permissions", () => ({
assertPermission: vi.fn(),
}));
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { assertPermission } from "../utils/permissions";
const mockGetAllByWorkspaceId = webhookRepo.getAllByWorkspaceId as ReturnType<typeof vi.fn>;
const mockGetByPublicId = webhookRepo.getByPublicId as ReturnType<typeof vi.fn>;
const mockCreate = webhookRepo.create as ReturnType<typeof vi.fn>;
const mockUpdate = webhookRepo.update as ReturnType<typeof vi.fn>;
const mockHardDelete = webhookRepo.hardDelete as ReturnType<typeof vi.fn>;
const mockWorkspaceGetByPublicId = workspaceRepo.getByPublicId as ReturnType<typeof vi.fn>;
const mockAssertPermission = assertPermission as ReturnType<typeof vi.fn>;
// We need to import the router after mocks are set up
// Testing approach: call the internal handler logic through a test wrapper
describe("webhook router", () => {
const mockDb = {} as never;
const mockUser = { id: "user-123", name: "Test User", email: "test@example.com" };
const mockWorkspace = { id: 1, publicId: "ws-123456789" };
const mockWebhook = {
id: 1,
publicId: "wh-123456789",
workspaceId: 1,
name: "My Webhook",
url: "https://example.com/webhook",
secret: "secret123",
events: ["card.created", "card.updated"] as const,
active: true,
createdAt: new Date("2024-01-15"),
updatedAt: null,
};
beforeEach(() => {
vi.clearAllMocks();
mockAssertPermission.mockResolvedValue(undefined);
});
describe("authorization", () => {
it("throws UNAUTHORIZED when user is not authenticated", async () => {
// Import fresh to get mocked version
const { webhookRouter } = await import("./webhook");
const ctx = {
user: null,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" }),
).rejects.toThrow(TRPCError);
});
it("throws NOT_FOUND when workspace does not exist", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(null);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-nonexistent" }),
).rejects.toThrow(TRPCError);
});
it("checks workspace:manage permission via assertPermission", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetAllByWorkspaceId.mockResolvedValueOnce([]);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" });
expect(mockAssertPermission).toHaveBeenCalledWith(
mockDb,
mockUser.id,
mockWorkspace.id,
"workspace:manage",
);
});
});
describe("list", () => {
it("returns all webhooks for workspace", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetAllByWorkspaceId.mockResolvedValueOnce([mockWebhook]);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).list({
workspacePublicId: "ws-123456789",
});
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe("My Webhook");
expect(mockGetAllByWorkspaceId).toHaveBeenCalledWith(mockDb, mockWorkspace.id);
});
it("returns empty array when no webhooks exist", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetAllByWorkspaceId.mockResolvedValueOnce([]);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).list({
workspacePublicId: "ws-123456789",
});
expect(result).toEqual([]);
});
});
describe("create", () => {
it("creates a webhook with valid input", async () => {
const { webhookRouter } = await import("./webhook");
const newWebhook = {
publicId: "wh-new123456",
name: "New Webhook",
url: "https://example.com/new",
events: ["card.created"] as const,
active: true,
createdAt: new Date(),
};
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockCreate.mockResolvedValueOnce(newWebhook);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).create({
workspacePublicId: "ws-123456789",
name: "New Webhook",
url: "https://example.com/new",
events: ["card.created"],
});
expect(result.name).toBe("New Webhook");
expect(mockCreate).toHaveBeenCalledWith(mockDb, {
workspaceId: mockWorkspace.id,
name: "New Webhook",
url: "https://example.com/new",
secret: undefined,
events: ["card.created"],
createdBy: mockUser.id,
});
});
it("creates a webhook with secret", async () => {
const { webhookRouter } = await import("./webhook");
const newWebhook = {
publicId: "wh-new123456",
name: "Secure Webhook",
url: "https://example.com/secure",
events: ["card.created"] as const,
active: true,
createdAt: new Date(),
};
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockCreate.mockResolvedValueOnce(newWebhook);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await webhookRouter.createCaller(ctx).create({
workspacePublicId: "ws-123456789",
name: "Secure Webhook",
url: "https://example.com/secure",
secret: "my-secret-key",
events: ["card.created"],
});
expect(mockCreate).toHaveBeenCalledWith(mockDb, expect.objectContaining({
secret: "my-secret-key",
}));
});
it("throws INTERNAL_SERVER_ERROR when create fails", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockCreate.mockResolvedValueOnce(null);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).create({
workspacePublicId: "ws-123456789",
name: "New Webhook",
url: "https://example.com/new",
events: ["card.created"],
}),
).rejects.toThrow(TRPCError);
});
});
describe("update", () => {
it("updates webhook name", async () => {
const { webhookRouter } = await import("./webhook");
const updatedWebhook = { ...mockWebhook, name: "Updated Name" };
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
mockUpdate.mockResolvedValueOnce(updatedWebhook);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).update({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
name: "Updated Name",
});
expect(result.name).toBe("Updated Name");
expect(mockUpdate).toHaveBeenCalledWith(mockDb, "wh-123456789", {
name: "Updated Name",
url: undefined,
secret: undefined,
events: undefined,
active: undefined,
});
});
it("throws NOT_FOUND when webhook does not exist", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(null);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).update({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-nonexistent",
name: "Updated Name",
}),
).rejects.toThrow(TRPCError);
});
it("throws NOT_FOUND when webhook belongs to different workspace", async () => {
const { webhookRouter } = await import("./webhook");
const webhookFromDifferentWorkspace = { ...mockWebhook, workspaceId: 999 };
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(webhookFromDifferentWorkspace);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).update({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
name: "Updated Name",
}),
).rejects.toThrow(TRPCError);
});
});
describe("delete", () => {
it("deletes webhook successfully", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
mockHardDelete.mockResolvedValueOnce(undefined);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).delete({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
});
expect(result).toEqual({ success: true });
expect(mockHardDelete).toHaveBeenCalledWith(mockDb, "wh-123456789");
});
it("throws NOT_FOUND when webhook does not exist", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(null);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).delete({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-nonexistent",
}),
).rejects.toThrow(TRPCError);
});
});
describe("test", () => {
beforeEach(() => {
global.fetch = vi.fn();
});
it("sends test payload to webhook URL", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).test({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
});
expect(result.success).toBe(true);
expect(result.statusCode).toBe(200);
expect(global.fetch).toHaveBeenCalledWith(
mockWebhook.url,
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": "application/json",
"X-Webhook-Event": "card.created",
}),
}),
);
});
it("returns error when test fails", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).test({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
});
expect(result.success).toBe(false);
expect(result.statusCode).toBe(500);
expect(result.error).toContain("500");
});
});
});

View File

@@ -0,0 +1,359 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { webhookEvents } from "@kan/db/schema";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
import {
webhookUrlSchema,
sendWebhookToUrl,
createCardWebhookPayload,
} from "../utils/webhook";
const webhookEventSchema = z.enum(webhookEvents);
export const webhookRouter = createTRPCRouter({
list: protectedProcedure
.meta({
openapi: {
summary: "Get all webhooks for a workspace",
method: "GET",
path: "/workspaces/{workspacePublicId}/webhooks",
description: "Retrieves all webhooks configured for a workspace",
tags: ["Webhooks"],
protect: true,
},
})
.input(z.object({ workspacePublicId: z.string().min(12) }))
.output(
z.array(
z.object({
publicId: z.string(),
name: z.string(),
url: z.string(),
events: z.array(webhookEventSchema),
active: z.boolean(),
createdAt: z.date(),
updatedAt: z.date().nullable(),
}),
),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
return webhookRepo.getAllByWorkspaceId(ctx.db, workspace.id);
}),
create: protectedProcedure
.meta({
openapi: {
summary: "Create a webhook",
method: "POST",
path: "/workspaces/{workspacePublicId}/webhooks",
description: "Creates a new webhook for a workspace",
tags: ["Webhooks"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
name: z.string().min(1).max(255),
url: webhookUrlSchema,
secret: z.string().max(512).optional(),
events: z.array(webhookEventSchema).min(1),
}),
)
.output(
z.object({
publicId: z.string(),
name: z.string(),
url: z.string(),
events: z.array(webhookEventSchema),
active: z.boolean(),
createdAt: z.date(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
const result = await webhookRepo.create(ctx.db, {
workspaceId: workspace.id,
name: input.name,
url: input.url,
secret: input.secret,
events: input.events,
createdBy: userId,
});
if (!result)
throw new TRPCError({
message: "Unable to create webhook",
code: "INTERNAL_SERVER_ERROR",
});
return result;
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a webhook",
method: "PUT",
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}",
description: "Updates a webhook by its public ID",
tags: ["Webhooks"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
webhookPublicId: z.string().min(12),
name: z.string().min(1).max(255).optional(),
url: webhookUrlSchema.optional(),
secret: z.string().max(512).optional(),
events: z.array(webhookEventSchema).min(1).optional(),
active: z.boolean().optional(),
}),
)
.output(
z.object({
publicId: z.string(),
name: z.string(),
url: z.string(),
events: z.array(webhookEventSchema),
active: z.boolean(),
createdAt: z.date(),
updatedAt: z.date().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
const webhook = await webhookRepo.getByPublicId(
ctx.db,
input.webhookPublicId,
);
if (!webhook || webhook.workspaceId !== workspace.id)
throw new TRPCError({
message: "Webhook not found",
code: "NOT_FOUND",
});
const result = await webhookRepo.update(ctx.db, input.webhookPublicId, {
name: input.name,
url: input.url,
secret: input.secret,
events: input.events,
active: input.active,
});
if (!result)
throw new TRPCError({
message: "Unable to update webhook",
code: "INTERNAL_SERVER_ERROR",
});
return result;
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a webhook",
method: "DELETE",
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}",
description: "Deletes a webhook by its public ID",
tags: ["Webhooks"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
webhookPublicId: 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",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
const webhook = await webhookRepo.getByPublicId(
ctx.db,
input.webhookPublicId,
);
if (!webhook || webhook.workspaceId !== workspace.id)
throw new TRPCError({
message: "Webhook not found",
code: "NOT_FOUND",
});
await webhookRepo.hardDelete(ctx.db, input.webhookPublicId);
return { success: true };
}),
test: protectedProcedure
.meta({
openapi: {
summary: "Test a webhook",
method: "POST",
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}/test",
description: "Sends a test payload to a webhook",
tags: ["Webhooks"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
webhookPublicId: z.string().min(12),
}),
)
.output(
z.object({
success: z.boolean(),
statusCode: z.number().optional(),
error: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
const webhook = await webhookRepo.getByPublicId(
ctx.db,
input.webhookPublicId,
);
if (!webhook || webhook.workspaceId !== workspace.id)
throw new TRPCError({
message: "Webhook not found",
code: "NOT_FOUND",
});
const testPayload = createCardWebhookPayload("card.created", {
id: "test-card-id",
title: "Test Card",
description: "This is a test webhook payload",
dueDate: null,
listId: "test-list-id",
}, {
boardId: "test-board-id",
boardName: "Test Board",
listName: "Test List",
user: {
id: userId,
name: ctx.user?.name ?? "Test User",
},
});
const result = await sendWebhookToUrl(
webhook.url,
webhook.secret ?? undefined,
testPayload,
);
return result;
}),
});

View File

@@ -4,11 +4,10 @@ import { z } from "zod";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
import { generateUID } from "@kan/shared/utils";
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
import { generateAvatarUrl } from "@kan/shared/utils";
export const workspaceRouter = createTRPCRouter({
all: protectedProcedure
@@ -84,8 +83,7 @@ export const workspaceRouter = createTRPCRouter({
const isAdmin = userMember?.role === "admin";
// Show emails if user is admin OR workspace setting allows it
const shouldShowEmails =
isAdmin || result.showEmailsToMembers === true;
const shouldShowEmails = isAdmin || result.showEmailsToMembers === true;
// Generate presigned URLs for member avatars
const membersWithAvatarUrls = await Promise.all(
@@ -295,6 +293,9 @@ export const workspaceRouter = createTRPCRouter({
.optional(),
description: z.string().min(3).max(280).optional(),
showEmailsToMembers: z.boolean().optional(),
weekStartDay: z
.union([z.literal(0), z.literal(1), z.literal(6)])
.optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
@@ -356,6 +357,7 @@ export const workspaceRouter = createTRPCRouter({
slug: input.slug,
description: input.description,
showEmailsToMembers: input.showEmailsToMembers,
weekStartDay: input.weekStartDay,
},
);

View File

@@ -1,7 +1,9 @@
import { randomUUID } from "crypto";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import type { NextApiRequest } from "next";
import type { OpenApiMeta } from "trpc-to-openapi";
import { initTRPC, TRPCError } from "@trpc/server";
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
import { env } from "next-runtime-env";
import superjson from "superjson";
import { ZodError } from "zod";
@@ -9,6 +11,9 @@ import { ZodError } from "zod";
import type { dbClient } from "@kan/db/client";
import { initAuth } from "@kan/auth/server";
import { createDrizzleClient } from "@kan/db/client";
import { createLogger } from "@kan/logger";
const log = createLogger("api");
export interface User {
id: string;
@@ -47,6 +52,7 @@ interface CreateContextOptions {
db: dbClient;
auth: ReturnType<typeof createAuthWithHeaders>;
headers: Headers;
transport?: "trpc" | "rest";
}
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
@@ -55,6 +61,8 @@ export const createInnerTRPCContext = (opts: CreateContextOptions) => {
db: opts.db,
auth: opts.auth,
headers: opts.headers,
transport: opts.transport ?? "trpc",
requestId: randomUUID(),
};
};
@@ -66,7 +74,13 @@ export const createTRPCContext = async ({ req }: CreateNextContextOptions) => {
const session = await auth.api.getSession();
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
return createInnerTRPCContext({
db,
user: session?.user,
auth,
headers,
transport: "trpc",
});
};
export const createNextApiContext = async (req: NextApiRequest) => {
@@ -77,7 +91,13 @@ export const createNextApiContext = async (req: NextApiRequest) => {
const session = await auth.api.getSession();
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
return createInnerTRPCContext({
db,
user: session?.user,
auth,
headers,
transport: "trpc",
});
};
export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
@@ -90,11 +110,17 @@ export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
try {
session = await auth.api.getSession();
} catch (error) {
console.error("Error getting session, ", error);
log.error({ err: error }, "Error getting session");
throw error;
}
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
return createInnerTRPCContext({
db,
user: session?.user,
auth,
headers,
transport: "rest",
});
};
const t = initTRPC
@@ -118,10 +144,45 @@ export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const publicProcedure = t.procedure.meta({
openapi: { method: "GET", path: "/public" },
const loggingMiddleware = t.middleware(async ({ path, type, next, ctx }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
const { user, transport, requestId } = ctx as {
user?: { id: string; email: string };
transport?: string;
requestId?: string;
};
const isCloud = process.env.NEXT_PUBLIC_KAN_ENV === "cloud";
const meta = {
requestId,
procedure: path,
type,
transport,
duration,
userId: user?.id,
...(isCloud && { email: user?.email }),
};
const label = transport === "rest" ? "REST" : "tRPC";
if (result.ok) {
log.info({ ...meta, status: 200 }, `${label} OK`);
} else {
const status = getHTTPStatusCodeFromError(result.error);
const errorCode = result.error.code;
log.error(
{ ...meta, status, errorCode, err: result.error },
`${label} error`,
);
}
return result;
});
export const publicProcedure = t.procedure.use(loggingMiddleware);
const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
@@ -142,14 +203,12 @@ const enforceUserIsAdmin = t.middleware(async ({ ctx, next }) => {
});
});
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed).meta({
openapi: {
method: "GET",
path: "/protected",
},
});
export const protectedProcedure = t.procedure
.use(loggingMiddleware)
.use(enforceUserIsAuthed);
export const adminProtectedProcedure = t.procedure
.use(loggingMiddleware)
.use(enforceUserIsAdmin)
.meta({
openapi: {

View File

@@ -0,0 +1,53 @@
import crypto from "crypto";
const ALGORITHM = "aes-256-gcm";
const SECRET_KEY = process.env.BETTER_AUTH_SECRET;
if (!SECRET_KEY) {
throw new Error("Encryption key is missing. Set BETTER_AUTH_SECRET.");
}
// Ensure the key is exactly 32 bytes
const key = crypto.createHash("sha256").update(String(SECRET_KEY)).digest();
export const encryptToken = (text: string) => {
const iv = crypto.randomBytes(12); // 12 bytes is the recommended IV size for GCM
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
// buffer concat is faster/cleaner for raw binary manipulation
const encrypted = Buffer.concat([
cipher.update(text, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
// Combine IV + AuthTag + EncryptedData into one buffer
// This saves space compared to storing them as separate hex strings
const combined = Buffer.concat([iv, authTag, encrypted]);
// Return as URL-safe Base64 (ideal for cookies)
return combined.toString("base64url");
};
export const decryptToken = (text: string) => {
// Convert URL-safe Base64 back to a Buffer
const combined = Buffer.from(text, "base64url");
// Extract the parts based on fixed lengths
// IV is 12 bytes (standard for GCM)
// AuthTag is 16 bytes (standard for GCM)
const iv = combined.subarray(0, 12);
const authTag = combined.subarray(12, 28);
const encryptedText = combined.subarray(28);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
// If the cookie was tampered with, this will throw an error
const decrypted = Buffer.concat([
decipher.update(encryptedText),
decipher.final(),
]);
return decrypted.toString("utf8");
};

View File

@@ -1,6 +1,9 @@
import { env } from "next-runtime-env";
import type { dbClient } from "@kan/db/client";
import { createLogger } from "@kan/logger";
const log = createLogger("notifications");
import * as cardRepo from "@kan/db/repository/card.repo";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as notificationRepo from "@kan/db/repository/notification.repo";
@@ -72,6 +75,7 @@ export async function sendMentionEmails({
const baseUrl = env("NEXT_PUBLIC_BASE_URL");
const cardUrl = `${baseUrl}/cards/${cardPublicId}`;
log.info({ cardPublicId, mentionCount: membersToNotify.length, commenterUserId }, "Sending mention emails");
// Send emails to all mentioned members (only if notification doesn't exist)
await Promise.all(
membersToNotify.map(async (member) => {
@@ -91,6 +95,7 @@ export async function sendMentionEmails({
// If notification already exists, skip sending email
if (notificationExists) {
log.debug({ email, cardPublicId }, "Skipping duplicate mention email");
return;
}
@@ -114,20 +119,14 @@ export async function sendMentionEmails({
cardUrl,
},
);
log.info({ email, cardPublicId }, "Mention email sent");
} catch (error) {
console.error("Failed to send mention email:", {
email,
cardPublicId,
error: error instanceof Error ? error.message : String(error),
});
log.error({ err: error, email, cardPublicId }, "Failed to send mention email");
}
}),
);
} catch (error) {
console.error("Error sending mention emails:", {
cardPublicId,
error: error instanceof Error ? error.message : String(error),
});
log.error({ err: error, cardPublicId }, "Error sending mention emails");
}
}

View File

@@ -5,6 +5,9 @@ import {
} from "rate-limiter-flexible";
import { getRedisClient } from "@kan/db/redis";
import { createLogger } from "@kan/logger";
const log = createLogger("rateLimit");
export interface RateLimitOptions {
points?: number;
@@ -45,7 +48,7 @@ function createRateLimiter(options: RateLimitOptions = {}) {
// Use Redis if available, otherwise fall back to in-memory storage
if (redis) {
console.log("Using Redis for rate limiting");
log.debug("Using Redis for rate limiting");
return new RateLimiterRedis({
storeClient: redis,
points,
@@ -53,7 +56,7 @@ function createRateLimiter(options: RateLimitOptions = {}) {
});
}
console.log("Using in-memory for rate limiting");
log.debug("Redis unavailable, falling back to in-memory rate limiting");
return new RateLimiterMemory({
points,
duration,

View File

@@ -404,11 +404,10 @@ describe("webhook utilities", () => {
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
// Event filtering now happens at DB level
// getActiveByWorkspaceId fetches all active webhooks; event filtering is client-side
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
mockDb,
1,
"card.created",
);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenCalledWith(
@@ -421,8 +420,8 @@ describe("webhook utilities", () => {
);
});
it("does not send when no webhooks match the event (DB-level filtering)", async () => {
// DB-level event filter returns empty array when no webhooks match
it("does not send when no webhooks match the event (client-side filtering)", async () => {
// Returns webhooks that don't match the event — client-side filter excludes them
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
@@ -430,7 +429,6 @@ describe("webhook utilities", () => {
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
mockDb,
1,
"card.created",
);
expect(global.fetch).not.toHaveBeenCalled();
});

View File

@@ -4,6 +4,9 @@ import { z } from "zod";
import type { dbClient } from "@kan/db/client";
import type { WebhookEvent } from "@kan/db/schema";
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import { createLogger } from "@kan/logger";
const log = createLogger("webhook");
export type WebhookEventType = WebhookEvent;
@@ -185,12 +188,13 @@ export async function sendWebhooksForWorkspace(
payload: WebhookPayload,
): Promise<void> {
try {
// Get active webhooks for this workspace
const webhooks = await webhookRepo.getActiveByWorkspaceId(db, workspaceId);
// Filter webhooks that are subscribed to this specific event
const webhooksForEvent = webhooks.filter((webhook) =>
webhook.events.includes(payload.event),
// Get active webhooks for this workspace and filter by event client-side
const allWebhooks = await webhookRepo.getActiveByWorkspaceId(
db,
workspaceId,
);
const webhooksForEvent = allWebhooks.filter((w) =>
w.events.includes(payload.event),
);
// Send to all subscribed webhooks in parallel (fire and forget)
@@ -198,9 +202,9 @@ export async function sendWebhooksForWorkspace(
sendWebhookToUrl(webhook.url, webhook.secret ?? undefined, payload).then(
(result) => {
if (!result.success) {
console.error(
`Webhook delivery failed to ${webhook.url}: ${result.error}`,
);
log.error({ url: webhook.url, event: payload.event, error: result.error, statusCode: result.statusCode }, "Webhook delivery failed");
} else {
log.info({ url: webhook.url, event: payload.event, statusCode: result.statusCode }, "Webhook delivered");
}
},
),
@@ -209,7 +213,7 @@ export async function sendWebhooksForWorkspace(
// Wait for all to complete but don't block on failures
await Promise.allSettled(promises);
} catch (error) {
console.error("Failed to send webhooks for workspace:", error);
log.error({ err: error, workspaceId }, "Failed to send webhooks for workspace");
}
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
import { resolve } from "path";
export default defineConfig({
test: {
include: ["src/**/*.test.ts", "integration-tests/**/*.test.ts"],
},
resolve: {
alias: {
"@kan/db": resolve(__dirname, "../db/src"),
},
},
});

View File

@@ -39,6 +39,7 @@
"prettier": "@kan/prettier-config",
"dependencies": {
"@better-auth/stripe": "^1.4.6",
"@kan/logger": "workspace:^",
"better-auth": "^1.4.6"
}
}

View File

@@ -7,8 +7,11 @@ import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import { notificationClient } from "@kan/email";
import { createLogger } from "@kan/logger";
import { createEmailUnsubscribeLink, createS3Client } from "@kan/shared";
const log = createLogger("auth");
import { downloadImage } from "./utils";
type BetterAuthUser = {
@@ -103,6 +106,7 @@ export function createDatabaseHooks(db: dbClient) {
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
log.info({ workflowId: "user-signup", userId: user.id, email: user.email }, "Triggering Novu workflow");
await notificationClient.trigger({
to: {
subscriberId: user.id,
@@ -122,6 +126,7 @@ export function createDatabaseHooks(db: dbClient) {
},
workflowId: "user-signup",
});
log.info({ workflowId: "user-signup", userId: user.id }, "Novu workflow triggered");
await notificationClient.subscribers.credentials.update(
{
@@ -134,7 +139,7 @@ export function createDatabaseHooks(db: dbClient) {
user.id,
);
} catch (error) {
console.error("Error adding user to notification client", error);
log.error({ err: error }, "Error adding user to notification client");
}
}
},

View File

@@ -8,7 +8,10 @@ import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as userRepo from "@kan/db/repository/user.repo";
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";
@@ -101,9 +104,7 @@ export function createPlugins(db: dbClient) {
unlimitedSeats: true,
},
);
console.log(
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
);
log.info({ subscriptionId: stripeSubscription.id }, "Pro subscription activated with unlimited seats");
const workspace = await workspaceRepo.getByPublicId(
db,
@@ -164,6 +165,13 @@ export function createPlugins(db: dbClient) {
: []),
apiKey({
enableSessionForAPIKeys: true,
customAPIKeyGetter: (ctx) => {
const authorization = ctx.headers?.get("authorization");
if (authorization?.startsWith("Bearer ")) {
return authorization.slice(7);
}
return ctx.headers?.get("x-api-key") ?? undefined;
},
rateLimit: {
enabled: true,
timeWindow: 1000 * 60, // 1 minute
@@ -175,11 +183,7 @@ export function createPlugins(db: dbClient) {
sendMagicLink: async ({ email, url }) => {
try {
const decodedUrl = decodeURIComponent(url);
console.log("Sending magic link to:", email, "URL:", url);
console.log(
"Magic link contains invite:",
decodedUrl.includes("type=invite"),
);
log.info({ email, isInvite: decodedUrl.includes("type=invite") }, "Sending magic link");
if (decodedUrl.includes("type=invite")) {
let inviterName = "";
let workspaceName = "";
@@ -211,7 +215,7 @@ export function createPlugins(db: dbClient) {
}
}
} catch (error) {
console.error("Failed to fetch invite details:", error);
log.error({ err: error }, "Failed to fetch invite details");
}
await sendEmail(
@@ -239,11 +243,7 @@ export function createPlugins(db: dbClient) {
);
}
} catch (error) {
console.error("Error sending magic link:", {
email,
url,
error,
});
log.error({ err: error, email }, "Error sending magic link");
}
},
}),
@@ -273,7 +273,7 @@ export function createPlugins(db: dbClient) {
picture?: string;
avatar?: string;
}) => {
console.log("OIDC profile:", profile);
log.debug({ profile }, "OIDC profile received");
const name =
profile.name ??

View File

@@ -4,8 +4,11 @@ import type Stripe from "stripe";
import type { dbClient } from "@kan/db/client";
import * as userRepo from "@kan/db/repository/user.repo";
import { notificationClient } from "@kan/email";
import { createLogger } from "@kan/logger";
import { createEmailUnsubscribeLink } from "@kan/shared";
const log = createLogger("auth");
export async function downloadImage(url: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) {
@@ -32,6 +35,7 @@ export async function triggerWorkflow(
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
log.info({ workflowId, userId: user.id }, "Triggering Novu workflow");
await notificationClient.trigger({
to: {
subscriberId: user.id,
@@ -43,7 +47,8 @@ export async function triggerWorkflow(
},
workflowId,
});
log.info({ workflowId, userId: user.id }, "Novu workflow triggered");
} catch (error) {
console.error("Error triggering workflow", error);
log.error({ err: error, workflowId }, "Error triggering workflow");
}
}

View File

@@ -0,0 +1,2 @@
ALTER TYPE "public"."source" ADD VALUE 'github';--> statement-breakpoint
ALTER TABLE "integration" ALTER COLUMN "accessToken" SET DATA TYPE text;--> statement-breakpoint

View File

@@ -0,0 +1,52 @@
-- Migrations and snapshots seem to have become out of sync (this should fix that)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_enum e
JOIN pg_type t ON e.enumtypid = t.oid
WHERE t.typname = 'source'
AND e.enumlabel = 'github'
) THEN
ALTER TYPE "public"."source" ADD VALUE 'github';
END IF;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "workspace_webhooks" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"workspaceId" bigint NOT NULL,
"name" varchar(255) NOT NULL,
"url" varchar(2048) NOT NULL,
"secret" text,
"events" text NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
CONSTRAINT "workspace_webhooks_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
ALTER TABLE "workspace_webhooks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "integration" ALTER COLUMN "accessToken" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "card_activity" ADD COLUMN IF NOT EXISTS "attachmentId" bigint;--> statement-breakpoint
--> statement-breakpoint
ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "weekStartDay" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_webhooks_workspace_idx" ON "workspace_webhooks" USING btree ("workspaceId");--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_attachmentId_card_attachment_id_fk" FOREIGN KEY ("attachmentId") REFERENCES "public"."card_attachment"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -187,16 +187,37 @@
{
"idx": 26,
"version": "7",
"when": 1771192000000,
"tag": "20260129210000_AddWorkspaceWebhooks",
"when": 1770521594167,
"tag": "20260208033314_AddAttachmentToActivity",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1771192000000,
"tag": "20260129210000_AddWorkspaceWebhooks",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1772049587901,
"tag": "20260225195947_AddIsArchivedToBoard",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1771930355536,
"tag": "20260224105235_AddGitHubIntegrationSupport",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1773212242728,
"tag": "20260311065722_AddWeekStartDay",
"breakpoints": true
}
]
}
}

View File

@@ -44,6 +44,7 @@
},
"dependencies": {
"@electric-sql/pglite": "^0.3.7",
"@kan/logger": "workspace:^",
"@kan/shared": "workspace:^",
"drizzle-orm": "^0.42.0",
"drizzle-zod": "^0.5.1",

View File

@@ -6,8 +6,12 @@ import { drizzle as drizzlePgLite } from "drizzle-orm/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
import { Pool } from "pg";
import { createLogger } from "@kan/logger";
import * as schema from "./schema";
const log = createLogger("db");
export type dbClient = NodePgDatabase<typeof schema> & {
$client: Pool;
};
@@ -16,7 +20,7 @@ export const createDrizzleClient = (): dbClient => {
const connectionString = process.env.POSTGRES_URL;
if (!connectionString) {
console.log("POSTGRES_URL environment variable is not set, using PGLite");
log.warn("POSTGRES_URL not set, falling back to PGLite");
const client = new PGlite({
dataDir: "./pgdata",

View File

@@ -46,6 +46,35 @@ export const getProvidersForUser = async (db: dbClient, userId: string) => {
return integration;
};
export const createOrUpdateProvider = async (
db: dbClient,
data: {
userId: string;
provider: string;
accessToken: string;
refreshToken?: string | null;
expiresAt: Date;
},
) => {
await db
.insert(integrations)
.values({
provider: data.provider,
userId: data.userId,
accessToken: data.accessToken,
refreshToken: data.refreshToken ?? null,
expiresAt: data.expiresAt,
})
.onConflictDoUpdate({
target: [integrations.userId, integrations.provider],
set: {
accessToken: data.accessToken,
refreshToken: data.refreshToken ?? null,
expiresAt: data.expiresAt,
},
});
};
export const deleteProviderForUser = async (
db: dbClient,
userId: string,

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