Compare commits
36 Commits
fix/migrat
...
feat/log-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c28fbf2668 | ||
|
|
67cf523ee8 | ||
|
|
0b45ceff7f | ||
|
|
76b2c58461 | ||
|
|
b8d1fe230d | ||
|
|
bd652ba6c9 | ||
|
|
1fb56aafd0 | ||
|
|
91deaca6ec | ||
|
|
ab81db0f56 | ||
|
|
29080fa851 | ||
|
|
2360c5075d | ||
|
|
79c2b5f3d5 | ||
|
|
8e7a95ff2b | ||
|
|
94fd2cb11f | ||
|
|
2a8af514d1 | ||
|
|
0b405e0456 | ||
|
|
f1f3a00c17 | ||
|
|
0fb2bac102 | ||
|
|
a7b71ae764 | ||
|
|
a246514b0b | ||
|
|
53397d8e81 | ||
|
|
1f9f07df20 | ||
|
|
1d5e3a936c | ||
|
|
0b49f502a9 | ||
|
|
400dcec56d | ||
|
|
dfcdc5e47e | ||
|
|
32e77e0291 | ||
|
|
a6780e3e28 | ||
|
|
2c5195a2b8 | ||
|
|
793fa30162 | ||
|
|
af63719de5 | ||
|
|
52fd624b81 | ||
|
|
b4d4810f45 | ||
|
|
239d152340 | ||
|
|
f294760747 | ||
|
|
280d8f66dd |
@@ -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)
|
||||
|
||||
8
.github/workflows/docker-publish.yml
vendored
8
.github/workflows/docker-publish.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -54,3 +54,5 @@ i18n.cache
|
||||
|
||||
# pgdata
|
||||
/apps/web/pgdata
|
||||
|
||||
.claude
|
||||
20
AGENTS.md
20
AGENTS.md
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
39
apps/web/public/manifest.json
Normal file
39
apps/web/public/manifest.json
Normal 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"
|
||||
}
|
||||
BIN
apps/web/public/screenshot-narrow.png
Normal file
BIN
apps/web/public/screenshot-narrow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
BIN
apps/web/public/screenshot-wide.png
Normal file
BIN
apps/web/public/screenshot-wide.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -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("");
|
||||
|
||||
@@ -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));
|
||||
|
||||
32
apps/web/src/components/FontSizeSelector.tsx
Normal file
32
apps/web/src/components/FontSizeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
130
apps/web/src/components/PlainTextEditor.tsx
Normal file
130
apps/web/src/components/PlainTextEditor.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />,
|
||||
|
||||
44
apps/web/src/hooks/useScrollRestore.ts
Normal file
44
apps/web/src/hooks/useScrollRestore.ts
Normal 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
55
apps/web/src/pages/404.tsx
Normal file
55
apps/web/src/pages/404.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
16
apps/web/src/pages/settings/webhooks.tsx
Normal file
16
apps/web/src/pages/settings/webhooks.tsx
Normal 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;
|
||||
57
apps/web/src/providers/font-size.tsx
Normal file
57
apps/web/src/providers/font-size.tsx
Normal 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;
|
||||
};
|
||||
@@ -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]);
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
[contenteditable="true"]:empty:before {
|
||||
content: attr(placeholder);
|
||||
display: block;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(/ /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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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"
|
||||
|
||||
78
apps/web/src/views/settings/WebhookSettings.tsx
Normal file
78
apps/web/src/views/settings/WebhookSettings.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
322
apps/web/src/views/settings/components/NewWebhookModal.tsx
Normal file
322
apps/web/src/views/settings/components/NewWebhookModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
295
apps/web/src/views/settings/components/WebhookList.tsx
Normal file
295
apps/web/src/views/settings/components/WebhookList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
75
packages/api/integration-tests/test-db.ts
Normal file
75
packages/api/integration-tests/test-db.ts
Normal 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! };
|
||||
}
|
||||
242
packages/api/integration-tests/webhook.integration.test.ts
Normal file
242
packages/api/integration-tests/webhook.integration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
423
packages/api/src/routers/webhook.test.ts
Normal file
423
packages/api/src/routers/webhook.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
359
packages/api/src/routers/webhook.ts
Normal file
359
packages/api/src/routers/webhook.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
53
packages/api/src/utils/encryption.ts
Normal file
53
packages/api/src/utils/encryption.ts
Normal 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");
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
packages/api/vitest.config.ts
Normal file
13
packages/api/vitest.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -39,6 +39,7 @@
|
||||
"prettier": "@kan/prettier-config",
|
||||
"dependencies": {
|
||||
"@better-auth/stripe": "^1.4.6",
|
||||
"@kan/logger": "workspace:^",
|
||||
"better-auth": "^1.4.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
52
packages/db/migrations/20260311065722_AddWeekStartDay.sql
Normal file
52
packages/db/migrations/20260311065722_AddWeekStartDay.sql
Normal 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
2983
packages/db/migrations/meta/20260224105235_snapshot.json
Normal file
2983
packages/db/migrations/meta/20260224105235_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3868
packages/db/migrations/meta/20260311065722_snapshot.json
Normal file
3868
packages/db/migrations/meta/20260311065722_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user