Compare commits

..

17 Commits

Author SHA1 Message Date
Henry
39bca3b39f chore: clean up types 2026-04-20 21:54:23 +01:00
Louie Chillingworth
b4b07f3120 fix: remove duplicate OIDC provider registration in getSocialProviders (#475)
The getSocialProviders endpoint was manually pushing "oidc" to the 
providers array when OIDC env vars are present, but Better Auth already 
includes the OIDC provider in ctx.context.socialProviders when it is 
configured. This resulted in ["oidc","oidc"] being returned from 
/api/auth/social-providers, causing a duplicate provider registration 
that led to a TypeError: Cannot read properties of null (reading 'id') 
during the OIDC login callback, preventing all OIDC logins.

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

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

* refactor: clean up

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

* feat: add workspace details step

* refactor: optimize board rendering in workspace details

* feat: tweak padding in boards mockup

* fix: validate/convert slug input

* refactor: tweak onboarding steps UX

* feat: add team to workspace plans

* feat: allow setting description when creating a workspace

* feat: add logging to next api routes

* feat: create worspace on checkout.session.completed

* chore: update types

* refactor: improve webhook logs

* feat: create workspace on successful activation

* feat: set slug on workspace name change

* feat: add returnUrl params
2026-04-11 07:59:26 +01:00
63 changed files with 4060 additions and 1652 deletions

View File

@@ -103,9 +103,9 @@ checksums:
tMYIu7/message: 8b1f0bb96a905646ecfad1cfdfa42168
tMYIu7/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
tMYIu7/translation: 8b1f0bb96a905646ecfad1cfdfa42168
UGCIzB/translation: f7b6699c16726afc48f1e1d1339b2e7e
UGCIzB/message: f7b6699c16726afc48f1e1d1339b2e7e
UGCIzB/origin/0/0: 0eeeb7e68164ff118040918f1ef00a7a
UGCIzB/translation: f7b6699c16726afc48f1e1d1339b2e7e
B3xxao/message: 9d80becc4fe65a74e0004e4fd290735d
B3xxao/origin/0/0: 6812b79a8fdc3527586b523d4c137340
B3xxao/translation: 9d80becc4fe65a74e0004e4fd290735d
@@ -238,6 +238,9 @@ checksums:
ZXSPJt/message: 1b5749b0cca6a62d75a577bee6804074
ZXSPJt/origin/0/0: 0627e0040ca4939c9a57349e98c51797
ZXSPJt/translation: 1b5749b0cca6a62d75a577bee6804074
YkfDoz/message: 5d4f1c56f4e9e579ec9967ca68cc64ac
YkfDoz/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
YkfDoz/translation: 5d4f1c56f4e9e579ec9967ca68cc64ac
3bqt9U/message: 2366ed295eb2c03c425559c24cb31606
3bqt9U/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
3bqt9U/translation: 2366ed295eb2c03c425559c24cb31606
@@ -319,6 +322,9 @@ checksums:
1rWxEw/message: 122c108794f321c10e05a08f09b11457
1rWxEw/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
1rWxEw/translation: 122c108794f321c10e05a08f09b11457
iH8pgl/message: f541015a827e37cb3b1234e56bc2aa3c
iH8pgl/origin/0/0: 69d8634b177fdf937764b406fd477547
iH8pgl/translation: f541015a827e37cb3b1234e56bc2aa3c
KNKCTb/message: 35919fd5b10ad212839a75e72829fe4e
KNKCTb/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
KNKCTb/translation: 35919fd5b10ad212839a75e72829fe4e
@@ -337,6 +343,9 @@ checksums:
UoSI%2Bi/message: 47f1051a134e111930be7f94179df4c8
UoSI%2Bi/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
UoSI%2Bi/translation: 47f1051a134e111930be7f94179df4c8
zt%2F6cS/message: aadb69a7f7421be6bb022fbde08a6d77
zt%2F6cS/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
zt%2F6cS/translation: aadb69a7f7421be6bb022fbde08a6d77
qaS%2B1%2F/message: 4e25347caf65e1b63bec90e101b86ddd
qaS%2B1%2F/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
qaS%2B1%2F/translation: 4e25347caf65e1b63bec90e101b86ddd
@@ -363,8 +372,7 @@ checksums:
QD8opX/origin/2/0: faa8a2576972b4eb31565d8ea6098f43
QD8opX/translation: 101ff39aab674c033c15ab978d0d8bac
YhvWXb/message: 336a8c70a91adc49c15266167ee84cc0
YhvWXb/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
YhvWXb/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
YhvWXb/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
YhvWXb/translation: 336a8c70a91adc49c15266167ee84cc0
i0ZMQl/message: f739058266a2cb6a3d7cca14de09c69a
i0ZMQl/origin/0/0: 0ad2af981579a62b084b3d41462c5546
@@ -526,20 +534,21 @@ checksums:
dEgA5A/origin/6/0: d55f532bb5ae247e3a018978be0da463
dEgA5A/origin/7/0: 4b4f55c033add8db69178563832227c6
dEgA5A/origin/8/0: 68450df287fb46ea629e818732c1291a
dEgA5A/origin/9/0: 03c00909b41be01231cd3e843c8ee44e
dEgA5A/origin/10/0: 0627e0040ca4939c9a57349e98c51797
dEgA5A/origin/11/0: 15792234f408027822ee71ccddde6f9b
dEgA5A/origin/12/0: 5c60161e173f5eae9276d8e4c70b4ebb
dEgA5A/origin/13/0: a1fde3fcce608870830f168f027727ff
dEgA5A/origin/14/0: 83911e3eacbad4583e2b1647a784d154
dEgA5A/origin/15/0: dbad0c8d7863cb41f5495264f82e7081
dEgA5A/origin/9/0: 15ea2dfd5eae4e05b226b1320103476e
dEgA5A/origin/10/0: 03c00909b41be01231cd3e843c8ee44e
dEgA5A/origin/11/0: 0627e0040ca4939c9a57349e98c51797
dEgA5A/origin/12/0: 15792234f408027822ee71ccddde6f9b
dEgA5A/origin/13/0: 5c60161e173f5eae9276d8e4c70b4ebb
dEgA5A/origin/14/0: a1fde3fcce608870830f168f027727ff
dEgA5A/origin/15/0: 83911e3eacbad4583e2b1647a784d154
dEgA5A/origin/16/0: dbad0c8d7863cb41f5495264f82e7081
dEgA5A/translation: 2e2a849c2223911717de8caa2c71bade
kryGs%2B/message: bba0beaced7ea954ceb980f2b022ffee
kryGs%2B/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
kryGs%2B/translation: bba0beaced7ea954ceb980f2b022ffee
sU2uWc/translation: 3fe52244676faf64e5266b6713d01e07
sU2uWc/message: 3fe52244676faf64e5266b6713d01e07
sU2uWc/origin/0/0: 97bd206a05414e2a571020d64e166093
sU2uWc/translation: 3fe52244676faf64e5266b6713d01e07
fEY2vP/message: 91509e2f92b0b3b11330b6983139fdbf
fEY2vP/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
fEY2vP/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
@@ -583,6 +592,9 @@ checksums:
EH8IL3/origin/0/0: c0b59968b2b234167c488a05cc38710b
EH8IL3/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
EH8IL3/translation: 6f79129c8f08ee54d858a2af57d16dd9
zMquA7/message: 38ff8d574865bb71d2f620bc4ba98c20
zMquA7/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
VHS0aJ/message: 31d0962985c83a29558c98a765bb1b16
VHS0aJ/origin/0/0: 15792234f408027822ee71ccddde6f9b
VHS0aJ/translation: 31d0962985c83a29558c98a765bb1b16
@@ -616,9 +628,8 @@ checksums:
dtQIWT/origin/0/0: c0b59968b2b234167c488a05cc38710b
dtQIWT/translation: 9d2308d578ec21bd22374e880fe3bd3c
AUYALh/message: ee2b0671e00972773210c5be5a9ccb89
AUYALh/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
AUYALh/origin/1/0: d77eb1f90f2fa86a55181629d88d237a
AUYALh/origin/2/0: dbad0c8d7863cb41f5495264f82e7081
AUYALh/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
AUYALh/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
AUYALh/translation: ee2b0671e00972773210c5be5a9ccb89
VbyRUy/message: 502237cd1044491c48494aa60967a985
VbyRUy/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
@@ -688,6 +699,10 @@ checksums:
AX4ktp/message: 4e6be22ade0188f408471325fe363575
AX4ktp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
AX4ktp/translation: 4e6be22ade0188f408471325fe363575
xGVfLh/message: 3cfba90b4600131e82fc4260c568d044
xGVfLh/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
xGVfLh/origin/1/0: 69d8634b177fdf937764b406fd477547
xGVfLh/translation: 3cfba90b4600131e82fc4260c568d044
J2Wbjt/message: 8ed03cf7c5e60a6edf3470a4558ff058
J2Wbjt/origin/0/0: d96af8a06c2e38a72238bcd16e426859
J2Wbjt/translation: 8ed03cf7c5e60a6edf3470a4558ff058
@@ -707,18 +722,18 @@ checksums:
F0YmUY/message: 9def232c9163d109f0e9093684cfe75f
F0YmUY/origin/0/0: 1b05beba6bdd908015fa8e798717f024
F0YmUY/translation: 9def232c9163d109f0e9093684cfe75f
0utuU6/translation: 9d7e453b24dfaf217352aeadc262074d
0utuU6/message: 9d7e453b24dfaf217352aeadc262074d
0utuU6/origin/0/0: 97bd206a05414e2a571020d64e166093
fDQ7hA/translation: 1d24ecbad9f2f0081ab51c7366e40506
0utuU6/translation: 9d7e453b24dfaf217352aeadc262074d
fDQ7hA/message: 1d24ecbad9f2f0081ab51c7366e40506
fDQ7hA/origin/0/0: 97bd206a05414e2a571020d64e166093
1bJKhj/translation: f61803ac8da8253e4ba35f92b3af1101
fDQ7hA/translation: 1d24ecbad9f2f0081ab51c7366e40506
1bJKhj/message: f61803ac8da8253e4ba35f92b3af1101
1bJKhj/origin/0/0: 0eeeb7e68164ff118040918f1ef00a7a
uvH2xS/translation: ecc2617544a6d385d18678577fa112fd
1bJKhj/translation: f61803ac8da8253e4ba35f92b3af1101
uvH2xS/message: ecc2617544a6d385d18678577fa112fd
uvH2xS/origin/0/0: 97bd206a05414e2a571020d64e166093
uvH2xS/translation: ecc2617544a6d385d18678577fa112fd
9PaIxv/message: da95932e7a1465a5d21aa3b855a46dc2
9PaIxv/origin/0/0: c0b59968b2b234167c488a05cc38710b
9PaIxv/translation: da95932e7a1465a5d21aa3b855a46dc2
@@ -815,9 +830,11 @@ checksums:
7RpHg%2F/origin/0/0: c0b59968b2b234167c488a05cc38710b
7RpHg%2F/origin/1/0: c0b59968b2b234167c488a05cc38710b
7RpHg%2F/translation: 66f702512197d25cfdb117f2415ecd88
ZDPJBc/message: f9154d0c02a7db907f0a33fb5edd4be1
ZDPJBc/origin/0/0: 69d8634b177fdf937764b406fd477547
ZDPJBc/translation: f9154d0c02a7db907f0a33fb5edd4be1
j9IZZ0/message: 7ba841d0946eb04fa3d17d74365be37b
j9IZZ0/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
j9IZZ0/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
j9IZZ0/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
j9IZZ0/translation: 7ba841d0946eb04fa3d17d74365be37b
OwneeH/message: e97476d280d4ebfd196d6c5336f3511f
OwneeH/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
@@ -970,16 +987,16 @@ checksums:
zxKs%2By/message: 0b6c8ad7aba0873b7e212d5f1949ca97
zxKs%2By/origin/0/0: db1e755dc7d73e037f425111bf7c30a6
zxKs%2By/translation: 0b6c8ad7aba0873b7e212d5f1949ca97
euc6Ns/translation: 27756566785c2b8463e21582c4bb619b
euc6Ns/message: 27756566785c2b8463e21582c4bb619b
euc6Ns/origin/0/0: 97bd206a05414e2a571020d64e166093
BTDPLo/translation: f6a33afcf5ff1489551c94867e083dca
euc6Ns/translation: 27756566785c2b8463e21582c4bb619b
BTDPLo/message: f6a33afcf5ff1489551c94867e083dca
BTDPLo/origin/0/0: 97bd206a05414e2a571020d64e166093
BTDPLo/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
06EQqT/translation: e9f56489fb395a890ae2557200c29a36
BTDPLo/translation: f6a33afcf5ff1489551c94867e083dca
06EQqT/message: e9f56489fb395a890ae2557200c29a36
06EQqT/origin/0/0: 97bd206a05414e2a571020d64e166093
06EQqT/translation: e9f56489fb395a890ae2557200c29a36
ePK91l/message: eee7f39ff90b18852afc1671f21fbaa9
ePK91l/origin/0/0: b697a20a9536be4dd8ecd143461eafc7
ePK91l/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
@@ -1026,9 +1043,12 @@ checksums:
IqjpYe/message: 81d41cf573a7109c376d30d905beb596
IqjpYe/origin/0/0: 5f6c4a0c9be5098099167871ae1564af
IqjpYe/translation: 81d41cf573a7109c376d30d905beb596
bFREhu/translation: b2cc9a311fc28d1594252f697c7f5342
bFREhu/message: b2cc9a311fc28d1594252f697c7f5342
bFREhu/origin/0/0: 97bd206a05414e2a571020d64e166093
bFREhu/translation: b2cc9a311fc28d1594252f697c7f5342
SUXBxp/message: 76e841ae8ec3340d7d0a5c2191abbea4
SUXBxp/origin/0/0: 69d8634b177fdf937764b406fd477547
SUXBxp/translation: 76e841ae8ec3340d7d0a5c2191abbea4
0%2BewPp/message: 785fe23c0eef0a5b60b5b2a88151de31
0%2BewPp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
0%2BewPp/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
@@ -1214,10 +1234,12 @@ checksums:
"%2B3TYXa/origin/0/0": 4980d8e27a628e7cd9a70b839d844083
"%2B3TYXa/translation": 9fa1c942feb40d6350322b71561df789
2POOFK/message: 0326365539c004f6088656f692602078
2POOFK/origin/0/0: c0b59968b2b234167c488a05cc38710b
2POOFK/origin/1/0: 9737a9cef0646ff4bbd942b1924b9a2c
2POOFK/origin/2/0: 9737a9cef0646ff4bbd942b1924b9a2c
2POOFK/origin/3/0: fadd5a5b6963ec5f261072f7e8d2140b
2POOFK/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
2POOFK/origin/1/0: 15ea2dfd5eae4e05b226b1320103476e
2POOFK/origin/2/0: c0b59968b2b234167c488a05cc38710b
2POOFK/origin/3/0: 9737a9cef0646ff4bbd942b1924b9a2c
2POOFK/origin/4/0: 9737a9cef0646ff4bbd942b1924b9a2c
2POOFK/origin/5/0: fadd5a5b6963ec5f261072f7e8d2140b
2POOFK/translation: 0326365539c004f6088656f692602078
uP4V6I/message: 2d403501f0488ec7d4eac979faf051a2
uP4V6I/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
@@ -1307,6 +1329,9 @@ checksums:
csFbe%2B/message: e4e58e33d637282d141df466d729bc7c
csFbe%2B/origin/0/0: 94d3e20c29bb517dd394711d7524e478
csFbe%2B/translation: e4e58e33d637282d141df466d729bc7c
SUvm1Y/message: 6c0f26526f77e9abb92fb2b1106a66db
SUvm1Y/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
SUvm1Y/translation: 6c0f26526f77e9abb92fb2b1106a66db
cdyS7J/message: 0f10050aeed2555a34fe3c27ce616607
cdyS7J/origin/0/0: c0b59968b2b234167c488a05cc38710b
cdyS7J/origin/1/0: c0b59968b2b234167c488a05cc38710b
@@ -1481,9 +1506,8 @@ checksums:
k7rCa%2F/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
k7rCa%2F/translation: 890b7851af05f97ac53644e4154c13b4
8Is1ih/message: 624592ba2ecf647a3fe60d493c1ee71a
8Is1ih/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
8Is1ih/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
8Is1ih/origin/2/0: dbad0c8d7863cb41f5495264f82e7081
8Is1ih/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
8Is1ih/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
8Is1ih/translation: 624592ba2ecf647a3fe60d493c1ee71a
EEHmPC/message: 8c1be80f5b2fcc112922282afbe30287
EEHmPC/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
@@ -1556,10 +1580,10 @@ checksums:
DbZgsJ/message: 7a1c63b75cea690b3fda6c3f203e31ff
DbZgsJ/origin/0/0: 0ad2af981579a62b084b3d41462c5546
DbZgsJ/translation: 7a1c63b75cea690b3fda6c3f203e31ff
hB02vO/translation: c13aa2a155ab4aff1c8fed6650a94e1a
hB02vO/message: c13aa2a155ab4aff1c8fed6650a94e1a
hB02vO/origin/0/0: fc68b5394b9b466d5d67df855060544d
hB02vO/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
hB02vO/translation: c13aa2a155ab4aff1c8fed6650a94e1a
IDvohQ/message: 35d0822f65971b97774b962561b02649
IDvohQ/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
IDvohQ/translation: 35d0822f65971b97774b962561b02649
@@ -1567,6 +1591,9 @@ checksums:
jl3aEJ/placeholders/0/0: 06f23520ebabfffed4999b5bcf61c9cd
jl3aEJ/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
jl3aEJ/translation: 4c38799ff25321ea25017bf4cd8e2e4f
vo2a%2Ba/message: fcf0f06f8b64b458c7ca6d95541a3cc8
vo2a%2Ba/origin/0/0: 69d8634b177fdf937764b406fd477547
vo2a%2Ba/translation: fcf0f06f8b64b458c7ca6d95541a3cc8
agPptk/message: 43983f2178fb28aacaef6c5f41282c4a
agPptk/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
agPptk/translation: 43983f2178fb28aacaef6c5f41282c4a
@@ -1597,18 +1624,19 @@ checksums:
hty0d5/origin/0/0: 04c86ea96f180745739d59e822da4026
hty0d5/translation: b4b1c8396e445e8b7956c747da2cf601
"%2B8Nek%2F/message": 818f1192e32bb855597f930d3e78806e
"%2B8Nek%2F/origin/0/0": 9737a9cef0646ff4bbd942b1924b9a2c
"%2B8Nek%2F/origin/1/0": 82a5cf8d69f098641eb357bf102cd504
"%2B8Nek%2F/origin/0/0": 15ea2dfd5eae4e05b226b1320103476e
"%2B8Nek%2F/origin/1/0": 9737a9cef0646ff4bbd942b1924b9a2c
"%2B8Nek%2F/origin/2/0": 82a5cf8d69f098641eb357bf102cd504
"%2B8Nek%2F/translation": 818f1192e32bb855597f930d3e78806e
vneRvS/message: e228107df80015377112d41f4f155cb3
vneRvS/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
vneRvS/translation: e228107df80015377112d41f4f155cb3
51UCsN/translation: ce14fde5e972c2b5276c25ddded817c4
51UCsN/message: ce14fde5e972c2b5276c25ddded817c4
51UCsN/origin/0/0: 0eeeb7e68164ff118040918f1ef00a7a
J4%2BOTA/translation: 6ad27e803483208b5313660b7b5791d3
51UCsN/translation: ce14fde5e972c2b5276c25ddded817c4
J4%2BOTA/message: 6ad27e803483208b5313660b7b5791d3
J4%2BOTA/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
J4%2BOTA/translation: 6ad27e803483208b5313660b7b5791d3
BOqTi5/message: 11edf0427766c26db541d46379dc3c16
BOqTi5/placeholders/0/0: 9db68a4b386b9fa1fd1d5797c32be100
BOqTi5/placeholders/1/0: b837a9e8bd06b58be95699b3ee663f43
@@ -1836,6 +1864,9 @@ checksums:
8RdLDU/message: d7820b1bf4efecc61ed89234567aaa9c
8RdLDU/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
8RdLDU/translation: d7820b1bf4efecc61ed89234567aaa9c
Oi%2BJ%2BN/message: 313804fe615d51e0f0cbd599161d1ade
Oi%2BJ%2BN/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
Iqh0Uv/message: 0cc9dcd6ac624383e9977f66d4b3b777
Iqh0Uv/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
Iqh0Uv/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
@@ -1908,15 +1939,17 @@ checksums:
tyHiOa/origin/36/0: 8628ddb6f4ded25c0f17e2cbda8207d3
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/origin/39/0: 69d8634b177fdf937764b406fd477547
tyHiOa/origin/40/0: 69d8634b177fdf937764b406fd477547
tyHiOa/origin/41/0: 03c00909b41be01231cd3e843c8ee44e
tyHiOa/origin/42/0: 5c60161e173f5eae9276d8e4c70b4ebb
tyHiOa/origin/43/0: 83911e3eacbad4583e2b1647a784d154
tyHiOa/origin/44/0: d465da67c0a870d198eb7f313569c137
tyHiOa/origin/45/0: 33bf266228f835e12c0bfb0e5efb3be9
tyHiOa/origin/46/0: ff48991e314b0b3e989a4dac59860fd5
tyHiOa/origin/47/0: 30791e26a172d47d3cb0693e82679246
tyHiOa/origin/48/0: dbad0c8d7863cb41f5495264f82e7081
tyHiOa/origin/49/0: 0dccc39d0debbf3e089cb6d292de762b
tyHiOa/translation: 21ffcf0b00e7cd7b64f7454a95762e1d
PZCqeW/message: 325dea6dd0348a27a6818db2c1340c98
PZCqeW/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
@@ -1930,9 +1963,9 @@ checksums:
fuwKpE/origin/4/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/5/0: faa8a2576972b4eb31565d8ea6098f43
fuwKpE/translation: 346a18a54564881c888b95873d03df44
nYNMZZ/translation: d5a8a1b38eaf02d02cd6ac2211b42a92
nYNMZZ/message: d5a8a1b38eaf02d02cd6ac2211b42a92
nYNMZZ/origin/0/0: 97bd206a05414e2a571020d64e166093
nYNMZZ/translation: d5a8a1b38eaf02d02cd6ac2211b42a92
aHCEmh/message: ce27f1aeacccc542a174c4b2bce022b0
aHCEmh/origin/0/0: 98e2302d0d8d12ce65c4140b87450673
aHCEmh/origin/1/0: 634473ef965836871efe40875a36b6ab
@@ -1954,8 +1987,9 @@ checksums:
zwBp5t/origin/0/0: d6920b20329a9efe5985a39a295d83e1
zwBp5t/translation: d740d0660e68c61db1853489356030ae
3fPjUY/message: 682b3c9feab30112b4454cb5bb7974b1
3fPjUY/origin/0/0: c0b59968b2b234167c488a05cc38710b
3fPjUY/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
3fPjUY/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
3fPjUY/origin/1/0: c0b59968b2b234167c488a05cc38710b
3fPjUY/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
3fPjUY/translation: 682b3c9feab30112b4454cb5bb7974b1
oyRjD%2F/message: 76a09fd843b496610f8cf1dced417b62
oyRjD%2F/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
@@ -2065,6 +2099,7 @@ checksums:
"%2FgaSVU/origin/0/0": 98e2302d0d8d12ce65c4140b87450673
"%2FgaSVU/origin/1/0": 634473ef965836871efe40875a36b6ab
"%2FgaSVU/origin/2/0": 634473ef965836871efe40875a36b6ab
"%2FgaSVU/origin/3/0": 69d8634b177fdf937764b406fd477547
"%2FgaSVU/translation": c60f4a1acf30e566861bf130f13b9ae7
GDvlUT/message: 53743bbb6ca938f5b893552e839d067f
GDvlUT/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
@@ -2113,9 +2148,9 @@ checksums:
K3iLeO/message: e3bf6f95419169f460fb6918a0afc512
K3iLeO/origin/0/0: 5e2ed4920ffd2f535ece848ff44900bd
K3iLeO/translation: e3bf6f95419169f460fb6918a0afc512
zYRVNp/translation: 8b36455dd5b43e56d2ab06e522c3bacc
zYRVNp/message: 8b36455dd5b43e56d2ab06e522c3bacc
zYRVNp/origin/0/0: 97bd206a05414e2a571020d64e166093
zYRVNp/translation: 8b36455dd5b43e56d2ab06e522c3bacc
wgNoIs/message: eedc7cdb02de467c15dc418a066a77f2
wgNoIs/origin/0/0: 3c57a918258af4b44c187f58d203345f
wgNoIs/origin/1/0: 3c57a918258af4b44c187f58d203345f
@@ -2152,6 +2187,9 @@ checksums:
Eit43O/message: b5d9a1edd988f455041aee9fa7fb3d63
Eit43O/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
Eit43O/translation: b5d9a1edd988f455041aee9fa7fb3d63
JjEJSy/message: 0154ff378b3f4329871378229e269fc7
JjEJSy/origin/0/0: 69d8634b177fdf937764b406fd477547
JjEJSy/translation: 0154ff378b3f4329871378229e269fc7
Tz0i8g/message: 8df6777277469c1fd88cc18dde2f1cc3
Tz0i8g/origin/0/0: 77c9f6139024b2069e4fdc5120f69cb5
Tz0i8g/origin/1/0: 94d3e20c29bb517dd394711d7524e478
@@ -2186,6 +2224,10 @@ checksums:
n1ekoW/message: ec7b8f314fe9bc6591006707484ede61
n1ekoW/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
n1ekoW/translation: ec7b8f314fe9bc6591006707484ede61
fcWrnU/message: 6d842546a84e919c519bf8cbffdf25a0
fcWrnU/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
fcWrnU/origin/1/0: 69d8634b177fdf937764b406fd477547
fcWrnU/translation: 6d842546a84e919c519bf8cbffdf25a0
mErq7F/message: 0dd2ae69be4618c1f9e615774a4509ca
mErq7F/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
mErq7F/translation: 0dd2ae69be4618c1f9e615774a4509ca
@@ -2221,6 +2263,9 @@ checksums:
wIb7Ng/message: 67659097ef6bcb194839858b2c4f3a3c
wIb7Ng/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
wIb7Ng/translation: 67659097ef6bcb194839858b2c4f3a3c
6sKjjx/message: 50861c0881fe87a77412417e5ffb3fbb
6sKjjx/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
6sKjjx/translation: 50861c0881fe87a77412417e5ffb3fbb
vnS6Rf/message: 003f7f93d44b0f5c2e40bf24e7af3bd7
vnS6Rf/origin/0/0: c0b59968b2b234167c488a05cc38710b
vnS6Rf/translation: 003f7f93d44b0f5c2e40bf24e7af3bd7
@@ -2260,6 +2305,9 @@ checksums:
D%2BNlUC/message: 803281c327a02a2feba182f4a2b38311
D%2BNlUC/origin/0/0: 358ea1b5babb2e9ccbf1a4c9f89dd3a5
D%2BNlUC/translation: 803281c327a02a2feba182f4a2b38311
KM6m8p/message: c621ea9404a37af289def443b309bf1b
KM6m8p/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
bff61F/message: 3f0ae9c914f16ceb9a0d7db868f4aa20
bff61F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
bff61F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
@@ -2311,9 +2359,9 @@ checksums:
C6gv54/message: 1573c77059bc92df17e661befda8d9b7
C6gv54/origin/0/0: 0ad2af981579a62b084b3d41462c5546
C6gv54/translation: 1573c77059bc92df17e661befda8d9b7
nKBUeF/translation: 07e3ce10820b8ab28643e38b2ed51f64
nKBUeF/message: 07e3ce10820b8ab28643e38b2ed51f64
nKBUeF/origin/0/0: 97bd206a05414e2a571020d64e166093
nKBUeF/translation: 07e3ce10820b8ab28643e38b2ed51f64
jlB2RY/message: 67a76bf346ab4b48563269e9d941a64a
jlB2RY/origin/0/0: 0627e0040ca4939c9a57349e98c51797
jlB2RY/translation: 67a76bf346ab4b48563269e9d941a64a
@@ -2365,6 +2413,12 @@ checksums:
vlxQFL/message: 730f33b8f1fc002c4f393ccd262b6664
vlxQFL/origin/0/0: afee530613110e248ffde6b56872ebed
vlxQFL/translation: 730f33b8f1fc002c4f393ccd262b6664
HQVm6e/message: ec66b543a593e9dabf47de5979892d8d
HQVm6e/origin/0/0: 69d8634b177fdf937764b406fd477547
HQVm6e/translation: ec66b543a593e9dabf47de5979892d8d
gKmoow/message: 83997c77cb0dc8c8c90f7ab706aa817f
gKmoow/origin/0/0: 69d8634b177fdf937764b406fd477547
gKmoow/translation: 83997c77cb0dc8c8c90f7ab706aa817f
OAYToL/message: 1c989752736fa41ecdd68ea890f16a15
OAYToL/origin/0/0: 15792234f408027822ee71ccddde6f9b
OAYToL/translation: 1c989752736fa41ecdd68ea890f16a15
@@ -2386,9 +2440,9 @@ checksums:
MHrjPM/message: 344e64395eaff6822a57d18623853e1a
MHrjPM/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
MHrjPM/translation: 344e64395eaff6822a57d18623853e1a
wK4OTM/translation: b2a89bfd8e13d77f626e84840602a603
wK4OTM/message: b2a89bfd8e13d77f626e84840602a603
wK4OTM/origin/0/0: 97bd206a05414e2a571020d64e166093
wK4OTM/translation: b2a89bfd8e13d77f626e84840602a603
Se%2FUIp/message: d60813ea824f373462471e092d136eed
Se%2FUIp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
Se%2FUIp/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
@@ -2451,6 +2505,7 @@ checksums:
YcyP2z/translation: 7495f1434c9188849bffd015811d5337
MxQXhL/message: c1c949477e121d73ed6683eddf9cd874
MxQXhL/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
MxQXhL/origin/1/0: 69d8634b177fdf937764b406fd477547
MxQXhL/translation: c1c949477e121d73ed6683eddf9cd874
WHHLh9/message: 99d0b17b78d44e15f13d876d155f0efc
WHHLh9/origin/0/0: 3a89a46ac678e34dc8eb687b07a4ad4b
@@ -2467,12 +2522,12 @@ checksums:
2QGEbi/message: 550198b2c87f06726a843c79c1026ed9
2QGEbi/origin/0/0: 4b4f55c033add8db69178563832227c6
2QGEbi/translation: 550198b2c87f06726a843c79c1026ed9
Oo4E6p/translation: af985cf72c47df76e2f2139a427d9948
Oo4E6p/message: af985cf72c47df76e2f2139a427d9948
Oo4E6p/origin/0/0: 97bd206a05414e2a571020d64e166093
K7k9u3/translation: 1cf9d227042f6a65adba38cb7296830d
Oo4E6p/translation: af985cf72c47df76e2f2139a427d9948
K7k9u3/message: 1cf9d227042f6a65adba38cb7296830d
K7k9u3/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
K7k9u3/translation: 1cf9d227042f6a65adba38cb7296830d
8yFGGF/message: 39025a0c53818438829603d213baef06
8yFGGF/origin/0/0: 68450df287fb46ea629e818732c1291a
8yFGGF/translation: 39025a0c53818438829603d213baef06
@@ -2485,6 +2540,9 @@ checksums:
A8jqN7/message: 656c93265d7e2bef1245b17d85f85ae5
A8jqN7/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
A8jqN7/translation: 656c93265d7e2bef1245b17d85f85ae5
Q60WUZ/message: 4284c33aed2f2c5e004ed0a7d7d9bf87
Q60WUZ/origin/0/0: 69d8634b177fdf937764b406fd477547
Q60WUZ/translation: 4284c33aed2f2c5e004ed0a7d7d9bf87
T7Mywq/message: 62899897d7bffcb1df3af77d460f0881
T7Mywq/origin/0/0: 0ad2af981579a62b084b3d41462c5546
T7Mywq/translation: 62899897d7bffcb1df3af77d460f0881
@@ -2571,12 +2629,14 @@ checksums:
7Ufoyg/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
7Ufoyg/translation: 5c418701b27b9acbc5dd199719007362
mx8%2B1u/message: f2949ca2dc18b0063af92e60779abb65
mx8%2B1u/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
mx8%2B1u/origin/0/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/1/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/2/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/3/0: fadd5a5b6963ec5f261072f7e8d2140b
mx8%2B1u/origin/4/0: dbad0c8d7863cb41f5495264f82e7081
mx8%2B1u/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
mx8%2B1u/origin/3/0: dbad0c8d7863cb41f5495264f82e7081
mx8%2B1u/translation: f2949ca2dc18b0063af92e60779abb65
8AwlaR/message: acb1fdc14ed875815ae9f8a5254bc634
8AwlaR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
8AwlaR/translation: acb1fdc14ed875815ae9f8a5254bc634
Ws%2B3X%2B/message: 905b10eeb09fdc5ac7fc757e935ee757
Ws%2B3X%2B/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
Ws%2B3X%2B/translation: 905b10eeb09fdc5ac7fc757e935ee757
@@ -2616,7 +2676,7 @@ checksums:
IelE1h/origin/2/0: 5f6c4a0c9be5098099167871ae1564af
IelE1h/translation: 972773025e763ddf53273df6d37a729a
cWtfn1/message: 9a91ea70f5eb55aac96640bf0fd7feff
cWtfn1/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
cWtfn1/origin/0/0: 69d8634b177fdf937764b406fd477547
cWtfn1/translation: 9a91ea70f5eb55aac96640bf0fd7feff
b3Thhd/message: 5b7b755f05330164dbfb00096c500ee3
b3Thhd/origin/0/0: 12c1fab543c8848bcb10107d47336736
@@ -2748,7 +2808,8 @@ checksums:
UlhdTP/origin/0/0: 83911e3eacbad4583e2b1647a784d154
UlhdTP/translation: bc5c267ce3f04de94ff1379cf2ca48a3
chyf4J/message: 7159d620b7ef5c5afef734989a5be08b
chyf4J/origin/0/0: 5f6c4a0c9be5098099167871ae1564af
chyf4J/origin/0/0: 69d8634b177fdf937764b406fd477547
chyf4J/origin/1/0: 5f6c4a0c9be5098099167871ae1564af
chyf4J/translation: 7159d620b7ef5c5afef734989a5be08b
Ax%2FhYa/message: d0e83b9a0304d7c36cf8c85d98783ce2
Ax%2FhYa/origin/0/0: 33bf266228f835e12c0bfb0e5efb3be9
@@ -2766,7 +2827,8 @@ checksums:
wtxjAY/translation: 1857292f9567c556c781a23bd7e10544
CozWO1/message: 855614bbf862fd6a9f6a3e76943f92d4
CozWO1/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
CozWO1/origin/1/0: 5f6c4a0c9be5098099167871ae1564af
CozWO1/origin/1/0: 69d8634b177fdf937764b406fd477547
CozWO1/origin/2/0: 5f6c4a0c9be5098099167871ae1564af
CozWO1/translation: 855614bbf862fd6a9f6a3e76943f92d4
ghSJ4U/message: 681f86c9ca31699fc5a3ca3790e5972c
ghSJ4U/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
@@ -2809,6 +2871,9 @@ checksums:
RtlIYB/message: df4783d593505766d78c5bc08d3d48ac
RtlIYB/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
RtlIYB/translation: df4783d593505766d78c5bc08d3d48ac
3WXdoZ/message: 4cbeab40f77975492a23eec428732440
3WXdoZ/origin/0/0: 69d8634b177fdf937764b406fd477547
3WXdoZ/translation: 4cbeab40f77975492a23eec428732440
aelko%2B/message: 41dd145ef56f539e12dc064a9807c660
aelko%2B/origin/0/0: 4980d8e27a628e7cd9a70b839d844083
aelko%2B/translation: 41dd145ef56f539e12dc064a9807c660
@@ -2852,6 +2917,9 @@ checksums:
GoDLB9/message: 8c8d944e07388c5877effdb2c2803dcf
GoDLB9/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
GoDLB9/translation: 8c8d944e07388c5877effdb2c2803dcf
c4nMcR/message: bf4f697079eef51c3ebe46ce79063895
c4nMcR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
c4nMcR/translation: bf4f697079eef51c3ebe46ce79063895
evg7%2BA/message: 403972e7a25afc2415762c1c2b1ec868
evg7%2BA/origin/0/0: 3c57a918258af4b44c187f58d203345f
evg7%2BA/translation: 403972e7a25afc2415762c1c2b1ec868
@@ -2888,6 +2956,12 @@ checksums:
25fAUC/message: 3e4cce96bc1197a1caa2b7fb4d3c7e53
25fAUC/origin/0/0: d1e1d7ccce60e05570bedfbb93fc9416
25fAUC/translation: 3e4cce96bc1197a1caa2b7fb4d3c7e53
GRAGsB/message: 751a534fd6e62a92ff64c46894c7db86
GRAGsB/origin/0/0: 69d8634b177fdf937764b406fd477547
GRAGsB/translation: 751a534fd6e62a92ff64c46894c7db86
j0o6ml/message: 1e036ffca9d9cb9e2941b83fbbf375c0
j0o6ml/origin/0/0: 69d8634b177fdf937764b406fd477547
j0o6ml/translation: 1e036ffca9d9cb9e2941b83fbbf375c0
GnSSGm/message: 98de6f65c567c6654cc0adce3c9d5656
GnSSGm/origin/0/0: 33bf266228f835e12c0bfb0e5efb3be9
GnSSGm/translation: 98de6f65c567c6654cc0adce3c9d5656
@@ -2900,6 +2974,10 @@ checksums:
Hg1qE9/message: c808949b9b2b4a9aba2472f5d1050167
Hg1qE9/origin/0/0: 30791e26a172d47d3cb0693e82679246
Hg1qE9/translation: c808949b9b2b4a9aba2472f5d1050167
OTxjpR/message: 370a32c49489db61978ac91b55699c24
OTxjpR/origin/0/0: 69d8634b177fdf937764b406fd477547
OTxjpR/origin/1/0: 69d8634b177fdf937764b406fd477547
OTxjpR/translation: 370a32c49489db61978ac91b55699c24
4DOCMI/message: 0b48896061a1124501fdaba026804148
4DOCMI/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
4DOCMI/translation: 0b48896061a1124501fdaba026804148

View File

@@ -85,7 +85,7 @@ export interface RenderSuggestionsProps {
export interface WorkspaceMember {
publicId: string;
user: {
id: string;
id: string | null;
name: string | null;
image: string | null;
} | null;

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -20,13 +20,13 @@ const workspaceSlugSchema = z
interface CheckoutSessionRequest {
successUrl: string;
cancelUrl: string;
plan: "team" | "pro";
billing?: string;
workspacePublicId?: string;
slug?: string;
workspaceName?: string;
workspaceDescription?: string;
workspaceSlug?: string;
plan?: string;
}
export default withRateLimit(
@@ -38,117 +38,114 @@ export default withRateLimit(
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user, db } = await createNextApiContext(req);
const { user, db } = await createNextApiContext(req);
if (!user) {
return res.status(404).json({ error: "User not found" });
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const body = req.body as CheckoutSessionRequest;
const {
successUrl,
cancelUrl,
plan,
billing,
workspacePublicId,
slug,
workspaceName,
workspaceDescription,
workspaceSlug,
} = body;
if (!successUrl || !cancelUrl || !plan) {
return res.status(400).json({ error: "Missing required fields" });
}
if (!workspacePublicId && !workspaceName) {
return res
.status(400)
.json({ error: "Must provide workspacePublicId or workspaceName" });
}
const resolvedSlug = slug ?? workspaceSlug;
if (resolvedSlug) {
const slugResult = workspaceSlugSchema.safeParse(resolvedSlug);
if (!slugResult.success) {
return res.status(400).json({ error: "Invalid workspace slug" });
}
}
const body = req.body as CheckoutSessionRequest;
const {
successUrl,
cancelUrl,
billing,
let resolvedWorkspacePublicId = workspacePublicId;
let subscriptionId: number | undefined;
if (workspacePublicId) {
// Existing workspace upgrade
const workspace = await workspaceRepo.getByPublicId(
db,
workspacePublicId,
slug,
workspaceName,
workspaceDescription,
workspaceSlug,
} = body;
);
if (!successUrl || !cancelUrl) {
return res.status(400).json({ error: "Missing required fields" });
if (!workspace) {
return res.status(404).json({ error: "Workspace not found" });
}
if (!workspacePublicId && !workspaceName) {
return res
.status(400)
.json({ error: "Must provide workspacePublicId or workspaceName" });
try {
await assertPermission(db, user.id, workspace.id, "workspace:manage");
} catch {
return res.status(403).json({ error: "Unauthorized" });
}
const resolvedSlug = slug ?? workspaceSlug;
if (resolvedSlug) {
const slugResult = workspaceSlugSchema.safeParse(resolvedSlug);
if (!slugResult.success) {
return res.status(400).json({ error: "Invalid workspace slug" });
}
}
let resolvedWorkspacePublicId = workspacePublicId;
let subscriptionId: number | undefined;
if (workspacePublicId) {
// Existing workspace upgrade
const workspace = await workspaceRepo.getByPublicId(
db,
workspacePublicId,
);
if (!workspace) {
return res.status(404).json({ error: "Workspace not found" });
}
try {
await assertPermission(db, user.id, workspace.id, "workspace:manage");
} catch {
return res.status(403).json({ error: "Unauthorized" });
}
const subscription = await subscriptionRepo.create(db, {
plan: "pro",
referenceId: workspacePublicId,
userId: user.id,
stripeCustomerId: user.stripeCustomerId ?? "",
status: "incomplete",
});
subscriptionId = subscription?.id;
if (!subscriptionId) {
return res.status(500).json({ error: "Error creating subscription" });
}
} else {
resolvedWorkspacePublicId = generateUID();
}
const isTeam = body.plan === "team";
const annualPriceId = isTeam
? (process.env.STRIPE_TEAM_PLAN_ANNUAL_PRICE_ID ??
process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID)
: (process.env.STRIPE_PRO_PLAN_ANNUAL_PRICE_ID ??
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID);
const monthlyPriceId = isTeam
? process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID;
const priceId = billing === "annual" ? annualPriceId : monthlyPriceId;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_collection: "always",
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: { trial_period_days: 14 },
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}?workspacePublicId=${resolvedWorkspacePublicId}`,
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
client_reference_id: resolvedWorkspacePublicId,
customer: user.stripeCustomerId ?? undefined,
metadata: {
workspacePublicId: resolvedWorkspacePublicId!,
userId: user.id,
userEmail: user.email ?? "",
...(resolvedSlug && { workspaceSlug: resolvedSlug }),
...(workspaceName && { workspaceName }),
...(workspaceDescription && { workspaceDescription }),
...(subscriptionId && { subscriptionId: String(subscriptionId) }),
plan: body.plan ?? "pro",
isNewWorkspace: workspaceName ? "true" : "false",
},
const subscription = await subscriptionRepo.create(db, {
plan: plan,
referenceId: workspacePublicId,
userId: user.id,
stripeCustomerId: user.stripeCustomerId ?? "",
status: "incomplete",
});
return res.status(200).json({ url: session.url });
} catch (error) {
return res.status(500).json({ error: "Error creating checkout session" });
subscriptionId = subscription?.id;
if (!subscriptionId) {
return res.status(500).json({ error: "Error creating subscription" });
}
} else {
resolvedWorkspacePublicId = generateUID();
}
const isTeam = body.plan === "team";
const annualPriceId = isTeam
? (process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID ??
process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID)
: (process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID ??
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID);
const monthlyPriceId = isTeam
? process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID;
const priceId = billing === "annual" ? annualPriceId : monthlyPriceId;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_collection: "always",
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: { trial_period_days: 14 },
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}?workspacePublicId=${resolvedWorkspacePublicId}`,
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
client_reference_id: resolvedWorkspacePublicId,
customer: user.stripeCustomerId ?? undefined,
metadata: {
workspacePublicId: resolvedWorkspacePublicId!,
userId: user.id,
userEmail: user.email ?? "",
...(resolvedSlug && { workspaceSlug: resolvedSlug }),
...(workspaceName && { workspaceName }),
...(workspaceDescription && { workspaceDescription }),
...(subscriptionId && { subscriptionId: String(subscriptionId) }),
plan: plan,
isNewWorkspace: workspaceName ? "true" : "false",
},
});
return res.status(200).json({ url: session.url });
}),
);

View File

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

View File

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

View File

@@ -158,6 +158,9 @@ export function NewCardForm({
...member,
deletedAt: null,
})) ?? [],
comments: [],
checklists: [],
attachments: [],
_filteredLabels: labelPublicIds.map((id) => ({ publicId: id })),
_filteredMembers: memberPublicIds.map((id) => ({ publicId: id })),
index: position === "start" ? 0 : list.cards.length,
@@ -515,7 +518,7 @@ export function NewCardForm({
</div>
</div>
<div className="mt-5 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-5 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Create another`}
isChecked={isCreateAnotherEnabled}

View File

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

View File

@@ -4,6 +4,7 @@ import { useParams } from "next/navigation";
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { keepPreviousData } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { DragDropContext, Draggable } from "react-beautiful-dnd";
import { useForm } from "react-hook-form";
@@ -725,7 +726,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
if (
card.publicId.startsWith(
"PLACEHOLDER",
)
) ||
env("NEXT_PUBLIC_KAN_ENV") ===
"cloud"
)
return;
e.preventDefault();

View File

@@ -184,7 +184,7 @@ const SelectSource = ({
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button
type="submit"
@@ -310,7 +310,7 @@ const ImportGithub: React.FC = () => {
<form onSubmit={handleSubmitProjects(onSubmitProjects)}>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Select all`}
isChecked={!!isSelectAllEnabled}
@@ -459,7 +459,7 @@ const ImportTrello: React.FC = () => {
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Select all`}
isChecked={!!isSelectAllEnabled}

View File

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

View File

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

View File

@@ -341,7 +341,7 @@ export function InviteMemberForm({
)}
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={
isShareInviteLinkEnabled

View File

@@ -41,6 +41,7 @@ export default function WorkspaceNameView() {
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const { showPopup } = usePopup();
const [isProToggle, setIsProToggle] = useState(plan === "pro");
const effectivePlan = isProToggle ? "pro" : plan;
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
@@ -109,7 +110,7 @@ export default function WorkspaceNameView() {
const handleContinue = async () => {
if (!name.trim()) return;
if (plan === "solo") {
if (effectivePlan === "solo") {
createWorkspace.mutate({
name: name.trim(),
...(description.trim() && { description: description.trim() }),
@@ -128,11 +129,11 @@ export default function WorkspaceNameView() {
...(description.trim() && {
workspaceDescription: description.trim(),
}),
...(plan === "pro" && slug ? { workspaceSlug: slug } : {}),
cancelUrl: window.location.pathname + window.location.search,
...(effectivePlan === "pro" && slug ? { workspaceSlug: slug } : {}),
cancelUrl: `${window.location.pathname}?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
successUrl: "/boards",
billing,
plan,
plan: effectivePlan,
}),
});
const data = await response.json();
@@ -268,7 +269,7 @@ export default function WorkspaceNameView() {
variant="ghost"
onClick={() =>
router.replace(
`/onboarding/select-plan?plan=${plan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
)
}
>

View File

@@ -17,11 +17,11 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
export default function WorkspaceSettings() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,8 @@ export function UpgradeToProConfirmation({
body: JSON.stringify({
...(entityId && { slug: entityId }),
workspacePublicId: workspacePublicId,
plan: "pro",
billing: "monthly",
cancelUrl: "/settings",
successUrl: "/settings",
}),

View File

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

View File

@@ -61,7 +61,7 @@ services:
- 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_STORAGE_URL
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}

View File

@@ -8,6 +8,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { attachmentConfirmResponseSchema } from "../schemas";
import { assertPermission } from "../utils/permissions";
import { deleteObject, generateUploadUrl } from "@kan/shared/utils";
@@ -110,7 +111,7 @@ export const attachmentRouter = createTRPCRouter({
size: z.number().positive(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardAttachmentRepo.create>>>())
.output(attachmentConfirmResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

@@ -16,6 +16,13 @@ import {
} from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
boardListItemSchema,
boardDetailSchema,
boardBySlugSchema,
boardCreateResponseSchema,
boardUpdateResponseSchema,
} from "../schemas";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
export const boardRouter = createTRPCRouter({
@@ -37,9 +44,7 @@ export const boardRouter = createTRPCRouter({
archived: z.boolean().optional(),
}),
)
.output(
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
)
.output(z.array(boardListItemSchema))
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -106,7 +111,7 @@ export const boardRouter = createTRPCRouter({
type: z.enum(["regular", "template"]).optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
.output(boardDetailSchema)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -246,7 +251,7 @@ export const boardRouter = createTRPCRouter({
.optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>())
.output(boardBySlugSchema.nullable())
.query(async ({ ctx, input }) => {
const workspace = await workspaceRepo.getBySlugWithBoards(
ctx.db,
@@ -299,7 +304,7 @@ export const boardRouter = createTRPCRouter({
sourceBoardPublicId: z.string().min(12).optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
.output(boardCreateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -469,7 +474,7 @@ export const boardRouter = createTRPCRouter({
isArchived: z.boolean().optional(),
}),
)
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
.output(boardUpdateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

@@ -10,6 +10,14 @@ import * as listRepo from "@kan/db/repository/list.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
cardCreateResponseSchema,
cardUpdateResponseSchema,
cardDetailSchema,
commentResponseSchema,
commentDeleteResponseSchema,
activityItemSchema,
} from "../schemas";
import { mergeActivities } from "../utils/activities";
import { sendMentionEmails } from "../utils/notifications";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
@@ -42,7 +50,7 @@ export const cardRouter = createTRPCRouter({
dueDate: z.date().nullable().optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardRepo.create>>>())
.output(cardCreateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -181,7 +189,7 @@ export const cardRouter = createTRPCRouter({
title: input.title,
description: input.description,
dueDate: input.dueDate ?? null,
listId: String(newCard.listId),
listId: list.publicId,
},
{
boardId: list.boardPublicId,
@@ -215,7 +223,7 @@ export const cardRouter = createTRPCRouter({
comment: z.string().min(1),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.create>>>())
.output(commentResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -288,7 +296,7 @@ export const cardRouter = createTRPCRouter({
comment: z.string().min(1),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.update>>>())
.output(commentResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -376,7 +384,7 @@ export const cardRouter = createTRPCRouter({
commentPublicId: z.string().min(12),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.softDelete>>>())
.output(commentDeleteResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -435,7 +443,7 @@ export const cardRouter = createTRPCRouter({
createdBy: userId,
});
return deletedComment;
return { publicId: input.commentPublicId };
}),
addOrRemoveLabel: protectedProcedure
.meta({
@@ -639,25 +647,7 @@ export const cardRouter = createTRPCRouter({
},
})
.input(z.object({ cardPublicId: z.string().min(12) }))
.output(
z.custom<
Omit<
NonNullable<
Awaited<ReturnType<typeof cardRepo.getWithListAndMembersByPublicId>>
>,
"attachments"
> & {
attachments: {
publicId: string;
contentType: string;
s3Key: string;
originalFilename: string | null;
size?: number | null;
url: string | null;
}[];
}
>(),
)
.output(cardDetailSchema)
.query(async ({ ctx, input }) => {
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
ctx.db,
@@ -763,15 +753,7 @@ export const cardRouter = createTRPCRouter({
)
.output(
z.object({
activities: z.array(
z.custom<
NonNullable<
Awaited<
ReturnType<typeof cardActivityRepo.getPaginatedActivities>
>
>["activities"][number]
>(),
),
activities: z.array(activityItemSchema),
hasMore: z.boolean(),
nextCursor: z.string().datetime().nullable(),
}),
@@ -871,7 +853,7 @@ export const cardRouter = createTRPCRouter({
dueDate: z.date().nullable().optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardRepo.update>>>())
.output(cardUpdateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -906,9 +888,18 @@ export const cardRouter = createTRPCRouter({
);
let newListId: number | undefined;
let newList:
| {
id: number;
publicId: string;
name: string;
boardId: number;
index: number;
}
| undefined;
if (input.listPublicId) {
const newList = await listRepo.getByPublicId(
newList = await listRepo.getByPublicId(
ctx.db,
input.listPublicId,
);
@@ -1055,8 +1046,19 @@ export const cardRouter = createTRPCRouter({
) {
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
}
if (newListId && existingCard.listId !== newListId) {
webhookChanges.listId = { from: existingCard.listId, to: newListId };
const movedToNewList = Boolean(newListId && existingCard.listId !== newListId);
const currentWebhookListPublicId = movedToNewList
? input.listPublicId!
: existingCard.list.publicId;
const currentWebhookListName = movedToNewList
? newList?.name ?? card.listName
: existingCard.list.name;
if (movedToNewList) {
webhookChanges.listId = {
from: existingCard.list.publicId,
to: input.listPublicId!,
};
}
// Fire webhooks (non-blocking)
@@ -1064,20 +1066,18 @@ export const cardRouter = createTRPCRouter({
ctx.db,
card.workspaceId,
createCardWebhookPayload(
newListId && existingCard.listId !== newListId
? "card.moved"
: "card.updated",
movedToNewList ? "card.moved" : "card.updated",
{
id: String(result.id),
title: result.title,
description: result.description,
dueDate: result.dueDate,
listId: String(newListId ?? existingCard.listId),
listId: currentWebhookListPublicId,
},
{
boardId: card.boardPublicId,
boardName: card.boardName,
listName: card.listName,
listName: currentWebhookListName,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,
@@ -1167,12 +1167,12 @@ export const cardRouter = createTRPCRouter({
title: fullCard.title,
description: fullCard.description,
dueDate: fullCard.dueDate,
listId: String(fullCard.listId),
listId: fullCard.list.publicId,
},
{
boardId: card.boardPublicId,
boardName: card.boardName,
listName: card.listName,
listName: fullCard.list.name,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,

View File

@@ -7,6 +7,7 @@ import * as activityRepo from "@kan/db/repository/cardActivity.repo";
import * as listRepo from "@kan/db/repository/list.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { listCreateResponseSchema, listUpdateResponseSchema } from "../schemas";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
export const listRouter = createTRPCRouter({
@@ -27,7 +28,7 @@ export const listRouter = createTRPCRouter({
boardPublicId: z.string().min(12),
}),
)
.output(z.custom<Awaited<ReturnType<typeof listRepo.create>>>())
.output(listCreateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -163,12 +164,7 @@ export const listRouter = createTRPCRouter({
index: z.number().optional(),
}),
)
.output(
z.custom<
| Awaited<ReturnType<typeof listRepo.update>>
| Awaited<ReturnType<typeof listRepo.reorder>>
>(),
)
.output(listUpdateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

@@ -16,6 +16,7 @@ import {
import { updateSubscriptionSeats } from "@kan/stripe";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { memberInviteResponseSchema } from "../schemas";
import {
assertCanManageMember,
assertCanManageRole,
@@ -40,7 +41,7 @@ export const memberRouter = createTRPCRouter({
workspacePublicId: z.string().min(12),
}),
)
.output(z.custom<Awaited<ReturnType<typeof memberRepo.create>>>())
.output(memberInviteResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

@@ -7,6 +7,14 @@ import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
workspaceListItemSchema,
workspaceDetailSchema,
workspaceWithBoardsSchema,
workspaceCreateResponseSchema,
workspaceUpdateResponseSchema,
workspaceDeleteResponseSchema,
} from "../schemas";
import { assertPermission } from "../utils/permissions";
export const workspaceRouter = createTRPCRouter({
@@ -22,9 +30,7 @@ export const workspaceRouter = createTRPCRouter({
},
})
.input(z.void())
.output(
z.custom<Awaited<ReturnType<typeof workspaceRepo.getAllByUserId>>>(),
)
.output(z.array(workspaceListItemSchema))
.query(async ({ ctx }) => {
const userId = ctx.user?.id;
@@ -50,11 +56,7 @@ export const workspaceRouter = createTRPCRouter({
},
})
.input(z.object({ workspacePublicId: z.string().min(12) }))
.output(
z.custom<
Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>
>(),
)
.output(workspaceDetailSchema)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -134,7 +136,7 @@ export const workspaceRouter = createTRPCRouter({
return {
...result,
members: sanitizedMembers,
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
};
}
return {
@@ -162,9 +164,7 @@ export const workspaceRouter = createTRPCRouter({
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}),
)
.output(
z.custom<Awaited<ReturnType<typeof workspaceRepo.getBySlugWithBoards>>>(),
)
.output(workspaceWithBoardsSchema)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -211,7 +211,7 @@ export const workspaceRouter = createTRPCRouter({
.optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.create>>>())
.output(workspaceCreateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
const userEmail = ctx.user?.email;
@@ -270,7 +270,13 @@ export const workspaceRouter = createTRPCRouter({
code: "INTERNAL_SERVER_ERROR",
});
return result;
return {
publicId: result.publicId,
name: result.name!,
slug: result.slug!,
description: result.description ?? null,
plan: result.plan!,
};
}),
update: protectedProcedure
.meta({
@@ -300,7 +306,7 @@ export const workspaceRouter = createTRPCRouter({
.optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
.output(workspaceUpdateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -383,7 +389,7 @@ export const workspaceRouter = createTRPCRouter({
},
})
.input(z.object({ workspacePublicId: z.string().min(12) }))
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.hardDelete>>>())
.output(workspaceDeleteResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -405,12 +411,12 @@ export const workspaceRouter = createTRPCRouter({
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
const result = await workspaceRepo.hardDelete(
await workspaceRepo.hardDelete(
ctx.db,
input.workspacePublicId,
);
return result;
return { success: true };
}),
checkSlugAvailability: publicProcedure
.meta({

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
// ─── attachment.confirm ──────────────────────────────────────
export const attachmentConfirmResponseSchema = z.object({
publicId: z.string(),
filename: z.string(),
originalFilename: z.string(),
contentType: z.string(),
size: z.number(),
s3Key: z.string(),
createdAt: z.date(),
});

View File

@@ -0,0 +1,130 @@
import { z } from "zod";
import {
checklistResponseSchema,
labelSchema,
workspaceMemberSchema,
} from "./common";
// ─── board.all ───────────────────────────────────────────────
export const boardListItemSchema = z.object({
publicId: z.string(),
name: z.string(),
favorite: z.boolean(),
lists: z.array(
z.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
}),
),
labels: z.array(labelSchema),
});
// ─── Card sub-object inside board detail (byId) ─────────────
const boardCardMemberSchema = z.object({
publicId: z.string(),
email: z.string(),
user: z
.object({
name: z.string().nullable(),
email: z.string(),
image: z.string().nullable(),
})
.nullable(),
});
const boardDetailCardSchema = z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
index: z.number(),
dueDate: z.date().nullable(),
labels: z.array(labelSchema),
members: z.array(boardCardMemberSchema),
attachments: z.array(z.object({ publicId: z.string() })),
checklists: z.array(checklistResponseSchema),
comments: z.array(z.object({ publicId: z.string() })),
});
// ─── board.byId ──────────────────────────────────────────────
export const boardDetailSchema = z.object({
publicId: z.string(),
name: z.string(),
slug: z.string(),
visibility: z.string(),
isArchived: z.boolean(),
favorite: z.boolean(),
workspace: z.object({
publicId: z.string(),
members: z.array(workspaceMemberSchema),
}),
labels: z.array(labelSchema),
lists: z.array(
z.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
cards: z.array(boardDetailCardSchema),
}),
),
allLists: z.array(
z.object({
publicId: z.string(),
name: z.string(),
}),
),
});
// ─── Card sub-object inside board detail (bySlug — no members) ─
const boardSlugCardSchema = z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
index: z.number(),
dueDate: z.date().nullable(),
labels: z.array(labelSchema),
attachments: z.array(z.object({ publicId: z.string() })),
checklists: z.array(checklistResponseSchema),
comments: z.array(z.object({ publicId: z.string() })),
});
// ─── board.bySlug ────────────────────────────────────────────
export const boardBySlugSchema = z.object({
publicId: z.string(),
name: z.string(),
slug: z.string(),
visibility: z.string(),
workspace: z.object({
publicId: z.string(),
name: z.string(),
slug: z.string(),
}),
labels: z.array(labelSchema),
lists: z.array(
z.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
cards: z.array(boardSlugCardSchema),
}),
),
allLists: z.array(
z.object({
publicId: z.string(),
name: z.string(),
}),
),
});
// ─── board.create / board.createFromSnapshot ─────────────────
export const boardCreateResponseSchema = z.object({
publicId: z.string(),
name: z.string(),
});
// ─── board.update ────────────────────────────────────────────
export const boardUpdateResponseSchema = z.union([
z.object({ success: z.boolean() }),
z.object({ publicId: z.string(), name: z.string() }),
]);

View File

@@ -0,0 +1,216 @@
import { z } from "zod";
import {
checklistResponseSchema,
labelSchema,
workspaceMemberSchema,
} from "./common";
// ─── card.create ─────────────────────────────────────────────
export const cardCreateResponseSchema = z.object({
publicId: z.string(),
});
// ─── card.update ─────────────────────────────────────────────
export const cardUpdateResponseSchema = z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
dueDate: z.date().nullable(),
});
// ─── Comment responses ───────────────────────────────────────
export const commentResponseSchema = z.object({
publicId: z.string(),
comment: z.string(),
});
export const commentDeleteResponseSchema = z.object({
publicId: z.string(),
});
// ─── card.byId ───────────────────────────────────────────────
const cardMemberSchema = z.object({
publicId: z.string(),
email: z.string(),
user: z
.object({
id: z.string().nullable(),
name: z.string().nullable(),
})
.nullable(),
});
export const cardDetailSchema = z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
dueDate: z.date().nullable(),
createdBy: z.string().nullable(),
labels: z.array(labelSchema),
attachments: z.array(
z.object({
publicId: z.string(),
contentType: z.string(),
s3Key: z.string(),
originalFilename: z.string().nullable(),
size: z.number().nullable(),
url: z.string().nullable(),
}),
),
checklists: z.array(checklistResponseSchema),
list: z.object({
publicId: z.string(),
name: z.string(),
board: z.object({
publicId: z.string(),
name: z.string(),
labels: z.array(labelSchema),
lists: z.array(
z.object({
publicId: z.string(),
name: z.string(),
}),
),
workspace: z.object({
publicId: z.string(),
members: z.array(workspaceMemberSchema),
}),
}),
}),
members: z.array(cardMemberSchema),
activities: z.array(
z.object({
publicId: z.string(),
type: z.string(),
createdAt: z.date(),
fromIndex: z.number().nullable(),
toIndex: z.number().nullable(),
fromTitle: z.string().nullable(),
toTitle: z.string().nullable(),
fromDescription: z.string().nullable(),
toDescription: z.string().nullable(),
fromDueDate: z.date().nullable(),
toDueDate: z.date().nullable(),
fromList: z
.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
})
.nullable(),
toList: z
.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
})
.nullable(),
label: z
.object({
publicId: z.string(),
name: z.string(),
})
.nullable(),
member: z
.object({
publicId: z.string(),
user: z
.object({
name: z.string().nullable(),
email: z.string(),
})
.nullable(),
})
.nullable(),
user: z
.object({
name: z.string().nullable(),
email: z.string(),
})
.nullable(),
comment: z
.object({
publicId: z.string(),
comment: z.string(),
createdBy: z.string().nullable(),
updatedAt: z.date().nullable(),
deletedAt: z.date().nullable(),
})
.nullable(),
}),
),
});
// ─── card.getActivities ──────────────────────────────────────
export const activityItemSchema = z.object({
publicId: z.string(),
type: z.string(),
createdAt: z.date(),
fromIndex: z.number().nullable(),
toIndex: z.number().nullable(),
fromTitle: z.string().nullable(),
toTitle: z.string().nullable(),
fromDescription: z.string().nullable(),
toDescription: z.string().nullable(),
fromDueDate: z.date().nullable(),
toDueDate: z.date().nullable(),
fromList: z
.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
})
.nullable(),
toList: z
.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
})
.nullable(),
label: z
.object({
publicId: z.string(),
name: z.string(),
})
.nullable(),
member: z
.object({
publicId: z.string(),
user: z
.object({
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string(),
image: z.string().nullable(),
})
.nullable(),
})
.nullable(),
user: z
.object({
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string(),
image: z.string().nullable(),
})
.nullable(),
comment: z
.object({
publicId: z.string(),
comment: z.string(),
createdBy: z.string().nullable(),
updatedAt: z.date().nullable(),
deletedAt: z.date().nullable(),
})
.nullable(),
attachment: z
.object({
publicId: z.string(),
filename: z.string(),
originalFilename: z.string().nullable(),
})
.nullable(),
});

View File

@@ -0,0 +1,40 @@
import { z } from "zod";
// Shared label schema used across board and card responses
export const labelSchema = z.object({
publicId: z.string(),
name: z.string(),
colourCode: z.string().nullable(),
});
// Shared checklist item schema
export const checklistItemResponseSchema = z.object({
publicId: z.string(),
title: z.string(),
completed: z.boolean(),
index: z.number(),
});
// Shared checklist schema with items
export const checklistResponseSchema = z.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
items: z.array(checklistItemResponseSchema),
});
// Shared user sub-object (used inside member/workspace responses)
export const userSchema = z.object({
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string(),
image: z.string().nullable(),
});
// Workspace member sub-object
export const workspaceMemberSchema = z.object({
publicId: z.string(),
email: z.string(),
status: z.string(),
user: userSchema.nullable(),
});

View File

@@ -0,0 +1,39 @@
export {
boardListItemSchema,
boardDetailSchema,
boardBySlugSchema,
boardCreateResponseSchema,
boardUpdateResponseSchema,
} from "./board";
export {
cardCreateResponseSchema,
cardUpdateResponseSchema,
cardDetailSchema,
commentResponseSchema,
commentDeleteResponseSchema,
activityItemSchema,
} from "./card";
export {
labelSchema,
checklistItemResponseSchema,
checklistResponseSchema,
userSchema,
workspaceMemberSchema,
} from "./common";
export {
workspaceListItemSchema,
workspaceDetailSchema,
workspaceWithBoardsSchema,
workspaceCreateResponseSchema,
workspaceUpdateResponseSchema,
workspaceDeleteResponseSchema,
} from "./workspace";
export { listCreateResponseSchema, listUpdateResponseSchema } from "./list";
export { memberInviteResponseSchema } from "./member";
export { attachmentConfirmResponseSchema } from "./attachment";

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
// ─── list.create ─────────────────────────────────────────────
export const listCreateResponseSchema = z.object({
publicId: z.string(),
name: z.string(),
});
// ─── list.update / list.reorder ──────────────────────────────
export const listUpdateResponseSchema = z.object({
publicId: z.string(),
name: z.string(),
});

View File

@@ -0,0 +1,6 @@
import { z } from "zod";
// ─── member.invite ───────────────────────────────────────────
export const memberInviteResponseSchema = z.object({
publicId: z.string(),
});

View File

@@ -0,0 +1,93 @@
import { z } from "zod";
import { userSchema } from "./common";
// ─── workspace.all ───────────────────────────────────────────
export const workspaceListItemSchema = z.object({
role: z.string(),
workspace: z.object({
publicId: z.string(),
name: z.string(),
description: z.string().nullable(),
slug: z.string(),
plan: z.enum(["free", "team", "pro", "enterprise"]),
weekStartDay: z.number().nullable(),
deletedAt: z.date().nullable(),
}),
});
// ─── workspace.byId ──────────────────────────────────────────
const workspaceMemberDetailSchema = z.object({
publicId: z.string(),
email: z.string().optional(),
role: z.string(),
status: z.string(),
createdAt: z.date(),
user: z
.object({
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string().optional(),
image: z.string().nullable(),
})
.nullable(),
});
const workspaceSubscriptionSchema = z.object({
plan: z.string(),
status: z.string(),
seats: z.number().nullable(),
unlimitedSeats: z.boolean().nullable(),
periodStart: z.date().nullable(),
periodEnd: z.date().nullable(),
});
export const workspaceDetailSchema = z.object({
publicId: z.string(),
name: z.string(),
slug: z.string(),
showEmailsToMembers: z.boolean().nullable(),
weekStartDay: z.number().nullable(),
members: z.array(workspaceMemberDetailSchema),
subscriptions: z.array(workspaceSubscriptionSchema),
});
// ─── workspace.bySlug ────────────────────────────────────────
export const workspaceWithBoardsSchema = z.object({
publicId: z.string(),
name: z.string(),
description: z.string().nullable(),
slug: z.string(),
boards: z.array(
z.object({
publicId: z.string(),
slug: z.string(),
name: z.string(),
}),
),
});
// ─── workspace.create ────────────────────────────────────────
export const workspaceCreateResponseSchema = z.object({
publicId: z.string(),
name: z.string(),
slug: z.string(),
description: z.string().nullable(),
plan: z.enum(["free", "team", "pro", "enterprise"]),
});
// ─── workspace.update ────────────────────────────────────────
export const workspaceUpdateResponseSchema = z.object({
publicId: z.string(),
name: z.string(),
slug: z.string(),
description: z.string().nullable(),
plan: z.enum(["free", "team", "pro", "enterprise"]),
showEmailsToMembers: z.boolean().nullable(),
weekStartDay: z.number().nullable(),
});
// ─── workspace.delete ────────────────────────────────────────
export const workspaceDeleteResponseSchema = z.object({
success: z.boolean(),
});

View File

@@ -20,8 +20,11 @@ export function withApiLogging(
const requestId = randomUUID();
const route = req.url?.split("?")[0] ?? "unknown";
const input = {
...(req.query && Object.keys(req.query).length > 0 && { query: req.query }),
...(req.body && typeof req.body === "object" && Object.keys(req.body).length > 0 && { body: req.body }),
...(req.query &&
Object.keys(req.query).length > 0 && { query: req.query }),
...(req.body &&
typeof req.body === "object" &&
Object.keys(req.body).length > 0 && { body: req.body }),
};
let statusCode = 200;
@@ -41,7 +44,16 @@ export function withApiLogging(
// unauthenticated or auth unavailable
}
await handler(req, res);
let handlerError: unknown;
try {
await handler(req, res);
} catch (err) {
handlerError = err;
statusCode = 500;
if (!res.headersSent) {
res.status(500).json({ error: "Internal server error" });
}
}
const duration = Date.now() - start;
const meta = {
@@ -53,6 +65,10 @@ export function withApiLogging(
...(isCloud && email && { email }),
...(Object.keys(input).length > 0 && { input }),
status: statusCode,
...(handlerError instanceof Error && {
error: handlerError.message,
stack: handlerError.stack,
}),
};
if (statusCode < 400) {

View File

@@ -1,9 +1,20 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockLogger } = vi.hoisted(() => ({
mockLogger: {
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@kan/db/repository/webhook.repo", () => ({
getActiveByWorkspaceId: vi.fn(),
}));
vi.mock("@kan/logger", () => ({
createLogger: vi.fn(() => mockLogger),
}));
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import {
sendWebhookToUrl,
@@ -155,6 +166,33 @@ describe("webhook utilities", () => {
title: { from: "Old Title", to: "Updated Title" },
});
});
it("preserves public list IDs in moved payloads", () => {
const payload = createCardWebhookPayload(
"card.moved",
{
id: "card-123",
title: "Moved Card",
listId: "list-public-done",
},
{
boardId: "board-789",
listName: "Done",
changes: {
listId: { from: "list-public-backlog", to: "list-public-done" },
},
},
);
expect(payload.data.card.listId).toBe("list-public-done");
expect(payload.data.list).toEqual({
id: "list-public-done",
name: "Done",
});
expect(payload.data.changes).toEqual({
listId: { from: "list-public-backlog", to: "list-public-done" },
});
});
});
describe("sendWebhookToUrl", () => {
@@ -434,8 +472,6 @@ describe("webhook utilities", () => {
});
it("continues sending to other webhooks when one fails", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
{
id: 1,
@@ -462,11 +498,15 @@ describe("webhook utilities", () => {
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Webhook delivery failed"),
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/webhook1",
event: "card.created",
error: "500 Error",
statusCode: 500,
}),
"Webhook delivery failed",
);
consoleSpy.mockRestore();
});
it("handles empty webhook list", async () => {
@@ -478,9 +518,6 @@ describe("webhook utilities", () => {
});
it("catches and logs DB errors without throwing", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mockGetActiveByWorkspaceId.mockRejectedValueOnce(
new Error("DB connection failed"),
);
@@ -490,12 +527,13 @@ describe("webhook utilities", () => {
sendWebhooksForWorkspace(mockDb, 1, mockPayload),
).resolves.toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to send webhooks for workspace:",
expect.any(Error),
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
workspaceId: 1,
}),
"Failed to send webhooks for workspace",
);
consoleSpy.mockRestore();
});
});

View File

@@ -170,7 +170,7 @@ export function createPlugins(db: dbClient) {
if (authorization?.startsWith("Bearer ")) {
return authorization.slice(7);
}
return ctx.headers?.get("x-api-key") ?? undefined;
return ctx.headers?.get("x-api-key") ?? null;
},
rateLimit: {
enabled: true,

View File

@@ -92,14 +92,6 @@ export const socialProvidersPlugin = () => ({
const providers = ctx.context.socialProviders.map((p) =>
p.id.toLowerCase(),
);
// Add OIDC provider if configured
if (
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_DISCOVERY_URL
) {
providers.push("oidc");
}
return ctx.json(providers);
},
),

View File

@@ -220,6 +220,7 @@ export const getByPublicId = async (
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
@@ -278,6 +279,7 @@ export const getByPublicId = async (
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,

View File

@@ -241,6 +241,14 @@ export const getByPublicId = (db: dbClient, cardPublicId: string) => {
listId: true,
dueDate: true,
},
with: {
list: {
columns: {
publicId: true,
name: true,
},
},
},
where: eq(cards.publicId, cardPublicId),
});
};
@@ -945,7 +953,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
with: {
list: {
columns: { name: true },
columns: { name: true, publicId: true },
with: {
board: {
columns: {
@@ -966,6 +974,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
createdBy: result.createdBy,
workspaceId: result.list.board.workspaceId,
workspaceVisibility: result.list.board.visibility,
listPublicId: result.list.publicId,
listName: result.list.name,
boardPublicId: result.list.board.publicId,
boardName: result.list.board.name,

View File

@@ -209,10 +209,12 @@ export const getByPublicId = async (db: dbClient, listPublicId: string) => {
return db.query.lists.findFirst({
columns: {
id: true,
publicId: true,
name: true,
boardId: true,
index: true,
},
where: eq(lists.publicId, listPublicId),
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
});
};
@@ -433,6 +435,7 @@ export const getWorkspaceAndListIdByListPublicId = async (
return result
? {
id: result.id,
publicId: listPublicId,
name: result.name,
createdBy: result.createdBy,
workspaceId: result.board.workspaceId,