Compare commits
79 Commits
fix/create
...
fix/file-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb10b45e95 | ||
|
|
2893e95597 | ||
|
|
89654c20b0 | ||
|
|
4442e67832 | ||
|
|
d7e8b5ae6b | ||
|
|
03e1e19464 | ||
|
|
a3353acf44 | ||
|
|
a4a807ed1d | ||
|
|
6b8ada8d90 | ||
|
|
22899cd14c | ||
|
|
833c69a73e | ||
|
|
d9cf363302 | ||
|
|
9212b1ca2e | ||
|
|
ede2475796 | ||
|
|
e32ad1cd32 | ||
|
|
bac22021ad | ||
|
|
8d8cfa3bae | ||
|
|
971348a397 | ||
|
|
d3920cd246 | ||
|
|
5af375825b | ||
|
|
861416a18e | ||
|
|
a7e78ad580 | ||
|
|
f288047a6a | ||
|
|
edb88525c0 | ||
|
|
d69051611c | ||
|
|
cf566e04c6 | ||
|
|
6ea622e7f7 | ||
|
|
0e7b7a072c | ||
|
|
f801d2a7eb | ||
|
|
d9157b6218 | ||
|
|
711ff7f610 | ||
|
|
742e8a1fab | ||
|
|
69ed5fd8f2 | ||
|
|
e21830138d | ||
|
|
f38533590d | ||
|
|
7d52e4eb88 | ||
|
|
692123433b | ||
|
|
8436644966 | ||
|
|
0c2b670baf | ||
|
|
584fe13244 | ||
|
|
cc6804c21d | ||
|
|
8512946102 | ||
|
|
6bebb14855 | ||
|
|
0adb68d547 | ||
|
|
e26c122d76 | ||
|
|
95321c1c0d | ||
|
|
f459c489f9 | ||
|
|
460008d340 | ||
|
|
4371fe2d4d | ||
|
|
60d8e1bd83 | ||
|
|
4459fbb4b2 | ||
|
|
507c553778 | ||
|
|
3d9e859b31 | ||
|
|
54dcd08d9a | ||
|
|
468aeef8b6 | ||
|
|
71205b67dc | ||
|
|
607672718c | ||
|
|
dd262ee50f | ||
|
|
82c8eef0c2 | ||
|
|
ea116268ff | ||
|
|
74384d841e | ||
|
|
c0962e9f70 | ||
|
|
3e6aec2d6b | ||
|
|
4658b2961c | ||
|
|
bc8b5463b3 | ||
|
|
9eee59a877 | ||
|
|
e32e400976 | ||
|
|
b4b07f3120 | ||
|
|
e39faa1172 | ||
|
|
b46979f539 | ||
|
|
f4237fffe6 | ||
|
|
1d9bb96367 | ||
|
|
bcb449e394 | ||
|
|
ae4bc77407 | ||
|
|
6056167063 | ||
|
|
adf91c7dfd | ||
|
|
bba9a94e45 | ||
|
|
e36eb64c59 | ||
|
|
201fe05493 |
@@ -23,8 +23,8 @@ SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
|
||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
||||
|
||||
# S3 storage (optional)
|
||||
S3_REGION=
|
||||
S3_ENDPOINT=
|
||||
S3_REGION= # required for AWS S3 (e.g. us-east-1); ignored by most S3-compatible providers — defaults to us-east-1 if unset
|
||||
S3_ENDPOINT= # set for S3-compatible providers (MinIO, R2, Spaces, etc.); leave blank for AWS S3
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_FORCE_PATH_STYLE=
|
||||
|
||||
@@ -227,6 +227,12 @@ We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING
|
||||
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
|
||||
</a>
|
||||
|
||||
## Sponsors ❤️
|
||||
|
||||
[<img height="100" alt="image" src="https://github.com/user-attachments/assets/e331c71f-ac86-46a6-bceb-ce276de094b0" />](https://www.testmuai.com)
|
||||
|
||||
Proudly sponsored by [TestMu AI (formerly LambdaTest)](https://www.testmuai.com) - an AI-native testing cloud platform built for modern engineering teams. Covering everything from autonomous test creation and fast execution to testing AI agents like chatbots and voice assistants. If you're serious about testing, go check them out.
|
||||
|
||||
## License 📝
|
||||
|
||||
Kan is licensed under the [AGPLv3 license](LICENSE).
|
||||
|
||||
@@ -11,6 +11,7 @@ RUN apk update && \
|
||||
apk add --no-cache libc6-compat && \
|
||||
rm -rf /var/cache/apk/* /tmp/* || true && \
|
||||
corepack enable && \
|
||||
corepack prepare pnpm@9.14.2 --activate && \
|
||||
npm install turbo@2.3.1 --global && \
|
||||
pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
@@ -109,13 +110,15 @@ ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Copy the standalone Next.js server
|
||||
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
||||
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/standalone/ ./
|
||||
# Copy static assets and public files
|
||||
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||
COPY --chown=1000:1000 --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||
|
||||
# Copy bootstrap script for runtime env var injection
|
||||
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||
COPY --chown=1000:1000 apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||
|
||||
USER 1000:1000
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["bootstrap.cjs"]
|
||||
|
||||
@@ -88,6 +88,12 @@ checksums:
|
||||
7L01XJ/message: c46571856723b03262fd33f511116298
|
||||
7L01XJ/origin/0/0: feacdaf2791aaddec128f463ffa2e807
|
||||
7L01XJ/translation: c46571856723b03262fd33f511116298
|
||||
JEt3q7/message: 233710e683076c7eb6400a5c5ddd1d2a
|
||||
JEt3q7/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
JEt3q7/translation: 233710e683076c7eb6400a5c5ddd1d2a
|
||||
mkehy7/message: 71c4ebc206483ae49a2bd3dd7b6038ba
|
||||
mkehy7/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
mkehy7/translation: 71c4ebc206483ae49a2bd3dd7b6038ba
|
||||
F6pfE9/message: 3e1ec025c4a50830bbb9ad57a176630a
|
||||
F6pfE9/origin/0/0: 4b563046ba98abb3777fb1c3560866aa
|
||||
F6pfE9/origin/1/0: f64cd62b620f454998523af2a4330278
|
||||
@@ -224,7 +230,8 @@ checksums:
|
||||
Z3krJD/origin/0/0: e9da4b3a0eedd68ed126437194a482fe
|
||||
Z3krJD/translation: 0077436d9f37bfd64f3ae076a5a05040
|
||||
WnEwDO/message: 2959fd276248208b65cb27ed46b20135
|
||||
WnEwDO/origin/0/0: d6f5120f072a821219608624f361384e
|
||||
WnEwDO/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
WnEwDO/origin/1/0: d6f5120f072a821219608624f361384e
|
||||
WnEwDO/translation: 2959fd276248208b65cb27ed46b20135
|
||||
j1%2BHPc/message: baf4c3cd580291f41458f9ed28102c49
|
||||
j1%2BHPc/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
@@ -238,9 +245,9 @@ checksums:
|
||||
ZXSPJt/message: 1b5749b0cca6a62d75a577bee6804074
|
||||
ZXSPJt/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
ZXSPJt/translation: 1b5749b0cca6a62d75a577bee6804074
|
||||
YkfDoz/translation: 5d4f1c56f4e9e579ec9967ca68cc64ac
|
||||
YkfDoz/message: 5d4f1c56f4e9e579ec9967ca68cc64ac
|
||||
YkfDoz/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
YkfDoz/translation: 5d4f1c56f4e9e579ec9967ca68cc64ac
|
||||
3bqt9U/message: 2366ed295eb2c03c425559c24cb31606
|
||||
3bqt9U/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
3bqt9U/translation: 2366ed295eb2c03c425559c24cb31606
|
||||
@@ -322,9 +329,9 @@ checksums:
|
||||
1rWxEw/message: 122c108794f321c10e05a08f09b11457
|
||||
1rWxEw/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
1rWxEw/translation: 122c108794f321c10e05a08f09b11457
|
||||
iH8pgl/translation: f541015a827e37cb3b1234e56bc2aa3c
|
||||
iH8pgl/message: f541015a827e37cb3b1234e56bc2aa3c
|
||||
iH8pgl/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
iH8pgl/translation: f541015a827e37cb3b1234e56bc2aa3c
|
||||
KNKCTb/message: 35919fd5b10ad212839a75e72829fe4e
|
||||
KNKCTb/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
KNKCTb/translation: 35919fd5b10ad212839a75e72829fe4e
|
||||
@@ -343,9 +350,9 @@ checksums:
|
||||
UoSI%2Bi/message: 47f1051a134e111930be7f94179df4c8
|
||||
UoSI%2Bi/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
UoSI%2Bi/translation: 47f1051a134e111930be7f94179df4c8
|
||||
zt%2F6cS/translation: aadb69a7f7421be6bb022fbde08a6d77
|
||||
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
|
||||
@@ -372,8 +379,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
|
||||
@@ -549,7 +555,11 @@ checksums:
|
||||
kryGs%2B/translation: bba0beaced7ea954ceb980f2b022ffee
|
||||
sU2uWc/message: 3fe52244676faf64e5266b6713d01e07
|
||||
sU2uWc/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
sU2uWc/origin/1/0: 1b05beba6bdd908015fa8e798717f024
|
||||
sU2uWc/translation: 3fe52244676faf64e5266b6713d01e07
|
||||
9trBXW/message: 0035c67fb6f70992effb263b0b30e6d4
|
||||
9trBXW/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
9trBXW/translation: 0035c67fb6f70992effb263b0b30e6d4
|
||||
fEY2vP/message: 91509e2f92b0b3b11330b6983139fdbf
|
||||
fEY2vP/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
fEY2vP/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
@@ -583,8 +593,9 @@ checksums:
|
||||
pFGfsR/origin/0/0: 82383b95848d24b9311ceaef0ec94bb6
|
||||
pFGfsR/translation: b0d0be4f63c16552b3aa795af29240d1
|
||||
5IXWmO/message: e9a430fcd298def74212238df0f680d6
|
||||
5IXWmO/origin/0/0: 4a1be3707d14c696a16f453d132fb248
|
||||
5IXWmO/origin/1/0: d6f5120f072a821219608624f361384e
|
||||
5IXWmO/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
5IXWmO/origin/1/0: 4a1be3707d14c696a16f453d132fb248
|
||||
5IXWmO/origin/2/0: d6f5120f072a821219608624f361384e
|
||||
5IXWmO/translation: e9a430fcd298def74212238df0f680d6
|
||||
GnRmu4/message: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||
GnRmu4/origin/0/0: a2dd1fc876bd42b9db7ca7aaa57ac5e2
|
||||
@@ -593,9 +604,13 @@ checksums:
|
||||
EH8IL3/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
EH8IL3/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
EH8IL3/translation: 6f79129c8f08ee54d858a2af57d16dd9
|
||||
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
|
||||
zMquA7/message: 38ff8d574865bb71d2f620bc4ba98c20
|
||||
zMquA7/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
|
||||
5EMoSo/message: d5825bc247cfd3e52e233f7dab8051c6
|
||||
5EMoSo/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
5EMoSo/origin/1/0: 619b4be43b9080346366c4bf6ba2253d
|
||||
5EMoSo/translation: d5825bc247cfd3e52e233f7dab8051c6
|
||||
VHS0aJ/message: 31d0962985c83a29558c98a765bb1b16
|
||||
VHS0aJ/origin/0/0: 15792234f408027822ee71ccddde6f9b
|
||||
VHS0aJ/translation: 31d0962985c83a29558c98a765bb1b16
|
||||
@@ -615,9 +630,10 @@ checksums:
|
||||
RRn9jf/origin/1/0: d6f5120f072a821219608624f361384e
|
||||
RRn9jf/translation: 210b6ff8727f976182ec3f29ea3c7667
|
||||
yz7wBu/message: 2c2e22f8424a1031de89063bd0022e16
|
||||
yz7wBu/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
yz7wBu/origin/1/0: afee530613110e248ffde6b56872ebed
|
||||
yz7wBu/origin/2/0: dcafca531d4dce1faef7788feabfb048
|
||||
yz7wBu/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
yz7wBu/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
yz7wBu/origin/2/0: afee530613110e248ffde6b56872ebed
|
||||
yz7wBu/origin/3/0: dcafca531d4dce1faef7788feabfb048
|
||||
yz7wBu/translation: 2c2e22f8424a1031de89063bd0022e16
|
||||
TcXcmk/message: a2da6c2339301e7c3ddf068c4ff9f7e8
|
||||
TcXcmk/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
@@ -629,9 +645,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
|
||||
@@ -701,10 +716,10 @@ checksums:
|
||||
AX4ktp/message: 4e6be22ade0188f408471325fe363575
|
||||
AX4ktp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
AX4ktp/translation: 4e6be22ade0188f408471325fe363575
|
||||
xGVfLh/translation: 3cfba90b4600131e82fc4260c568d044
|
||||
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
|
||||
@@ -736,6 +751,9 @@ checksums:
|
||||
uvH2xS/message: ecc2617544a6d385d18678577fa112fd
|
||||
uvH2xS/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
uvH2xS/translation: ecc2617544a6d385d18678577fa112fd
|
||||
7mrkyO/message: d3853b58e9b736eefb0f9bf400daf4d7
|
||||
7mrkyO/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
7mrkyO/translation: d3853b58e9b736eefb0f9bf400daf4d7
|
||||
9PaIxv/message: da95932e7a1465a5d21aa3b855a46dc2
|
||||
9PaIxv/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
9PaIxv/translation: da95932e7a1465a5d21aa3b855a46dc2
|
||||
@@ -832,12 +850,11 @@ checksums:
|
||||
7RpHg%2F/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
7RpHg%2F/origin/1/0: c0b59968b2b234167c488a05cc38710b
|
||||
7RpHg%2F/translation: 66f702512197d25cfdb117f2415ecd88
|
||||
ZDPJBc/translation: f9154d0c02a7db907f0a33fb5edd4be1
|
||||
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
|
||||
@@ -996,6 +1013,7 @@ checksums:
|
||||
BTDPLo/message: f6a33afcf5ff1489551c94867e083dca
|
||||
BTDPLo/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
BTDPLo/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
|
||||
BTDPLo/origin/2/0: 1b05beba6bdd908015fa8e798717f024
|
||||
BTDPLo/translation: f6a33afcf5ff1489551c94867e083dca
|
||||
06EQqT/message: e9f56489fb395a890ae2557200c29a36
|
||||
06EQqT/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
@@ -1049,9 +1067,9 @@ checksums:
|
||||
bFREhu/message: b2cc9a311fc28d1594252f697c7f5342
|
||||
bFREhu/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
bFREhu/translation: b2cc9a311fc28d1594252f697c7f5342
|
||||
SUXBxp/translation: 76e841ae8ec3340d7d0a5c2191abbea4
|
||||
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
|
||||
@@ -1121,6 +1139,9 @@ checksums:
|
||||
rarRW%2F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
rarRW%2F/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
rarRW%2F/translation: 91553b606a6ebed3534cd2e5635ce004
|
||||
sZixXc/message: db4e3e372abb1ac510c62b14d544f0f8
|
||||
sZixXc/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
sZixXc/translation: db4e3e372abb1ac510c62b14d544f0f8
|
||||
sVBn4K/message: 0d7979a25d290123ed8b8906bf468f1a
|
||||
sVBn4K/origin/0/0: d465da67c0a870d198eb7f313569c137
|
||||
sVBn4K/translation: 0d7979a25d290123ed8b8906bf468f1a
|
||||
@@ -1134,8 +1155,7 @@ checksums:
|
||||
x0kMO%2F/origin/0/0: 30791e26a172d47d3cb0693e82679246
|
||||
x0kMO%2F/translation: 9da6c012e1b512d8c7fbd812626c4675
|
||||
YaZDYI/message: 57cc47581c7faaffb3b1721b6352b576
|
||||
YaZDYI/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
YaZDYI/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
YaZDYI/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
YaZDYI/translation: 57cc47581c7faaffb3b1721b6352b576
|
||||
lsgnzd/message: 934c6867d9bdbe6099b2c0f5a3107f78
|
||||
lsgnzd/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
@@ -1244,6 +1264,9 @@ checksums:
|
||||
2POOFK/origin/4/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
2POOFK/origin/5/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
2POOFK/translation: 0326365539c004f6088656f692602078
|
||||
8LLt89/message: 9a78178dc1874543d35153ae325e2811
|
||||
8LLt89/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||
8LLt89/translation: 9a78178dc1874543d35153ae325e2811
|
||||
uP4V6I/message: 2d403501f0488ec7d4eac979faf051a2
|
||||
uP4V6I/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
uP4V6I/translation: 2d403501f0488ec7d4eac979faf051a2
|
||||
@@ -1332,9 +1355,9 @@ checksums:
|
||||
csFbe%2B/message: e4e58e33d637282d141df466d729bc7c
|
||||
csFbe%2B/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
csFbe%2B/translation: e4e58e33d637282d141df466d729bc7c
|
||||
SUvm1Y/translation: 6c0f26526f77e9abb92fb2b1106a66db
|
||||
SUvm1Y/message: 6c0f26526f77e9abb92fb2b1106a66db
|
||||
SUvm1Y/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
SUvm1Y/translation: 6c0f26526f77e9abb92fb2b1106a66db
|
||||
cdyS7J/message: 0f10050aeed2555a34fe3c27ce616607
|
||||
cdyS7J/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
cdyS7J/origin/1/0: c0b59968b2b234167c488a05cc38710b
|
||||
@@ -1380,6 +1403,9 @@ checksums:
|
||||
2Py5S%2F/message: fe1183a7439299210d8ab7379b8f4d01
|
||||
2Py5S%2F/origin/0/0: 83911e3eacbad4583e2b1647a784d154
|
||||
2Py5S%2F/translation: fe1183a7439299210d8ab7379b8f4d01
|
||||
VOwhhz/message: 613f43526ea128b2749f70878f10008c
|
||||
VOwhhz/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
VOwhhz/translation: 613f43526ea128b2749f70878f10008c
|
||||
iwr7AP/message: 27c24b56895defecd10b0171b39bc93f
|
||||
iwr7AP/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
iwr7AP/translation: 27c24b56895defecd10b0171b39bc93f
|
||||
@@ -1464,6 +1490,9 @@ checksums:
|
||||
"%2BdjOzj/origin/0/0": faa852a0c0e3c81fe948e394029c1ba5
|
||||
"%2BdjOzj/origin/1/0": 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
"%2BdjOzj/translation": ade922db1be6b26bc979565ce5de2bc7
|
||||
M2zwdn/message: 4c9c0d43434ad63a70e8f956d1900602
|
||||
M2zwdn/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
M2zwdn/translation: 4c9c0d43434ad63a70e8f956d1900602
|
||||
qPP4lD/message: 03eeed2d715e770259f722ba48a61ab3
|
||||
qPP4lD/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
qPP4lD/translation: 03eeed2d715e770259f722ba48a61ab3
|
||||
@@ -1509,9 +1538,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
|
||||
@@ -1531,6 +1559,12 @@ checksums:
|
||||
iQmbPb/message: ac2bcac75151e2fcd9eb768cea275a77
|
||||
iQmbPb/origin/0/0: 98e2302d0d8d12ce65c4140b87450673
|
||||
iQmbPb/translation: ac2bcac75151e2fcd9eb768cea275a77
|
||||
aDzbuM/message: b6cb6ee4c4dd2cbe0d5d6a0088dcd731
|
||||
aDzbuM/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
aDzbuM/translation: b6cb6ee4c4dd2cbe0d5d6a0088dcd731
|
||||
K3LUJr/message: 6dd9358a22160ebed51ab3f36e2bb0b1
|
||||
K3LUJr/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
K3LUJr/translation: 6dd9358a22160ebed51ab3f36e2bb0b1
|
||||
1njn7W/message: bb78ea2c6edd8662676c81a8a13ecb93
|
||||
1njn7W/origin/0/0: 358ea1b5babb2e9ccbf1a4c9f89dd3a5
|
||||
1njn7W/translation: bb78ea2c6edd8662676c81a8a13ecb93
|
||||
@@ -1595,20 +1629,26 @@ checksums:
|
||||
jl3aEJ/placeholders/0/0: 06f23520ebabfffed4999b5bcf61c9cd
|
||||
jl3aEJ/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
jl3aEJ/translation: 4c38799ff25321ea25017bf4cd8e2e4f
|
||||
vo2a%2Ba/translation: fcf0f06f8b64b458c7ca6d95541a3cc8
|
||||
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
|
||||
W%2FElkg/message: 1f527cd6d1ed602930bcaa303f503b51
|
||||
W%2FElkg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
W%2FElkg/translation: 1f527cd6d1ed602930bcaa303f503b51
|
||||
NwiNTb/message: 9840dd63c40b860a9de1488a07b86828
|
||||
NwiNTb/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||
NwiNTb/translation: 9840dd63c40b860a9de1488a07b86828
|
||||
OvoEq7/message: 1606dc30b369856b9dba1fe9aec425d2
|
||||
OvoEq7/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
OvoEq7/origin/1/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
OvoEq7/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
OvoEq7/translation: 1606dc30b369856b9dba1fe9aec425d2
|
||||
GnG6Oy/message: c7910e9020804dbe5c7022044c669e68
|
||||
GnG6Oy/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||
GnG6Oy/translation: c7910e9020804dbe5c7022044c669e68
|
||||
wlQNTg/message: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
wlQNTg/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
wlQNTg/origin/1/0: db1e755dc7d73e037f425111bf7c30a6
|
||||
@@ -1741,6 +1781,9 @@ checksums:
|
||||
TXO5TM/message: 7f1ed5d777cade7d62303e9e591bbf63
|
||||
TXO5TM/origin/0/0: feacdaf2791aaddec128f463ffa2e807
|
||||
TXO5TM/translation: 7f1ed5d777cade7d62303e9e591bbf63
|
||||
HOMO91/message: 8a7b468d8a18e30de7743e4d5841c87f
|
||||
HOMO91/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
HOMO91/translation: 8a7b468d8a18e30de7743e4d5841c87f
|
||||
hXMFoN/message: cedf633d99c77ff4356e089f2d98c0a6
|
||||
hXMFoN/origin/0/0: fc0c18a095a68277a0f90a115942683b
|
||||
hXMFoN/translation: cedf633d99c77ff4356e089f2d98c0a6
|
||||
@@ -1815,6 +1858,9 @@ checksums:
|
||||
Uworke/message: 213d63da450f35dabb3ab0e35e29feed
|
||||
Uworke/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
Uworke/translation: 213d63da450f35dabb3ab0e35e29feed
|
||||
nG7mus/message: 03326832c645f4ddb873a6e7eab8a639
|
||||
nG7mus/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
nG7mus/translation: 03326832c645f4ddb873a6e7eab8a639
|
||||
IrZaAn/message: 1fcebe9ddb46f722a57f195efddc695d
|
||||
IrZaAn/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
IrZaAn/translation: 1fcebe9ddb46f722a57f195efddc695d
|
||||
@@ -1827,6 +1873,9 @@ checksums:
|
||||
vwGkYB/message: 4c30501d085eaccea47af34212bb26a7
|
||||
vwGkYB/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
vwGkYB/translation: 4c30501d085eaccea47af34212bb26a7
|
||||
Xlkxmi/message: 3251e7239a9b349a86d5785a113cd6b9
|
||||
Xlkxmi/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
Xlkxmi/translation: 3251e7239a9b349a86d5785a113cd6b9
|
||||
lpIMne/message: 37ca1f4e0afc9a0b8e9617f767103c92
|
||||
lpIMne/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
lpIMne/translation: 37ca1f4e0afc9a0b8e9617f767103c92
|
||||
@@ -1868,9 +1917,12 @@ checksums:
|
||||
8RdLDU/message: d7820b1bf4efecc61ed89234567aaa9c
|
||||
8RdLDU/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
8RdLDU/translation: d7820b1bf4efecc61ed89234567aaa9c
|
||||
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
|
||||
Oi%2BJ%2BN/message: 313804fe615d51e0f0cbd599161d1ade
|
||||
Oi%2BJ%2BN/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
|
||||
GdgCoi/message: c6a08c771f97d86b02cf10d8f5f2d788
|
||||
GdgCoi/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||
GdgCoi/translation: c6a08c771f97d86b02cf10d8f5f2d788
|
||||
Iqh0Uv/message: 0cc9dcd6ac624383e9977f66d4b3b777
|
||||
Iqh0Uv/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
Iqh0Uv/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
@@ -1941,19 +1993,18 @@ checksums:
|
||||
tyHiOa/origin/34/0: afee530613110e248ffde6b56872ebed
|
||||
tyHiOa/origin/35/0: afee530613110e248ffde6b56872ebed
|
||||
tyHiOa/origin/36/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
tyHiOa/origin/37/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
tyHiOa/origin/38/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
tyHiOa/origin/37/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
tyHiOa/origin/38/0: 69d8634b177fdf937764b406fd477547
|
||||
tyHiOa/origin/39/0: 69d8634b177fdf937764b406fd477547
|
||||
tyHiOa/origin/40/0: 69d8634b177fdf937764b406fd477547
|
||||
tyHiOa/origin/41/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
tyHiOa/origin/42/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
tyHiOa/origin/43/0: 83911e3eacbad4583e2b1647a784d154
|
||||
tyHiOa/origin/44/0: d465da67c0a870d198eb7f313569c137
|
||||
tyHiOa/origin/45/0: 33bf266228f835e12c0bfb0e5efb3be9
|
||||
tyHiOa/origin/46/0: ff48991e314b0b3e989a4dac59860fd5
|
||||
tyHiOa/origin/47/0: 30791e26a172d47d3cb0693e82679246
|
||||
tyHiOa/origin/48/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
tyHiOa/origin/49/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
tyHiOa/origin/40/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
tyHiOa/origin/41/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
tyHiOa/origin/42/0: 83911e3eacbad4583e2b1647a784d154
|
||||
tyHiOa/origin/43/0: d465da67c0a870d198eb7f313569c137
|
||||
tyHiOa/origin/44/0: 33bf266228f835e12c0bfb0e5efb3be9
|
||||
tyHiOa/origin/45/0: ff48991e314b0b3e989a4dac59860fd5
|
||||
tyHiOa/origin/46/0: 30791e26a172d47d3cb0693e82679246
|
||||
tyHiOa/origin/47/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
tyHiOa/origin/48/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
tyHiOa/translation: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||
PZCqeW/message: 325dea6dd0348a27a6818db2c1340c98
|
||||
PZCqeW/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
@@ -1964,8 +2015,10 @@ checksums:
|
||||
fuwKpE/origin/1/0: fd0e66cef8d46e96bcf3143a12f9b6db
|
||||
fuwKpE/origin/2/0: fc0c18a095a68277a0f90a115942683b
|
||||
fuwKpE/origin/3/0: 1b05beba6bdd908015fa8e798717f024
|
||||
fuwKpE/origin/4/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
fuwKpE/origin/5/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
fuwKpE/origin/4/0: 1b05beba6bdd908015fa8e798717f024
|
||||
fuwKpE/origin/5/0: 1b05beba6bdd908015fa8e798717f024
|
||||
fuwKpE/origin/6/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
fuwKpE/origin/7/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
fuwKpE/translation: 346a18a54564881c888b95873d03df44
|
||||
nYNMZZ/message: d5a8a1b38eaf02d02cd6ac2211b42a92
|
||||
nYNMZZ/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
@@ -1996,7 +2049,8 @@ checksums:
|
||||
3fPjUY/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
3fPjUY/translation: 682b3c9feab30112b4454cb5bb7974b1
|
||||
oyRjD%2F/message: 76a09fd843b496610f8cf1dced417b62
|
||||
oyRjD%2F/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
oyRjD%2F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
oyRjD%2F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
oyRjD%2F/translation: 76a09fd843b496610f8cf1dced417b62
|
||||
Qtzfdk/message: a898be9e4610706b9b8146a209e8ba16
|
||||
Qtzfdk/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
@@ -2140,6 +2194,13 @@ checksums:
|
||||
SkCNhl/message: 137017b2b3e885c4894ca8c899af5bc2
|
||||
SkCNhl/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
|
||||
SkCNhl/translation: 137017b2b3e885c4894ca8c899af5bc2
|
||||
66Jmqh/message: 5f56ef5447d5d3ae81d26936ca8e6758
|
||||
66Jmqh/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
66Jmqh/translation: 5f56ef5447d5d3ae81d26936ca8e6758
|
||||
MpFIca/message: 6e3f470ef5468c5195ae09f518b4dea4
|
||||
MpFIca/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
MpFIca/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
MpFIca/translation: 6e3f470ef5468c5195ae09f518b4dea4
|
||||
e1v%2BJ3/message: 9aeca4f286e14966ef1c32d2abc74584
|
||||
e1v%2BJ3/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
e1v%2BJ3/translation: 9aeca4f286e14966ef1c32d2abc74584
|
||||
@@ -2183,17 +2244,26 @@ checksums:
|
||||
v3YoMA/message: 3fff865dc00435f82896fc302ea45630
|
||||
v3YoMA/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
v3YoMA/translation: 3fff865dc00435f82896fc302ea45630
|
||||
GXGbXi/message: 8686cb61f9bd3cd7ed100cc3af819458
|
||||
GXGbXi/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
GXGbXi/translation: 8686cb61f9bd3cd7ed100cc3af819458
|
||||
8AbRER/message: 3cb81c4829857556f5d117c9a23f7bcc
|
||||
8AbRER/origin/0/0: 81b173297e9c022d9ebde5f7018c95f9
|
||||
8AbRER/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
|
||||
8AbRER/origin/2/0: 2201bf679c663a732d7f1b3c3ce3897f
|
||||
8AbRER/translation: 3cb81c4829857556f5d117c9a23f7bcc
|
||||
2gHjVM/message: 873c4eb14a7e32e7fd7793047b6028a1
|
||||
2gHjVM/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
2gHjVM/origin/1/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
2gHjVM/origin/2/0: 0627e0040ca4939c9a57349e98c51797
|
||||
2gHjVM/origin/3/0: 0627e0040ca4939c9a57349e98c51797
|
||||
2gHjVM/translation: 873c4eb14a7e32e7fd7793047b6028a1
|
||||
Eit43O/message: b5d9a1edd988f455041aee9fa7fb3d63
|
||||
Eit43O/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
Eit43O/translation: b5d9a1edd988f455041aee9fa7fb3d63
|
||||
JjEJSy/translation: 0154ff378b3f4329871378229e269fc7
|
||||
JjEJSy/message: 0154ff378b3f4329871378229e269fc7
|
||||
JjEJSy/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
JjEJSy/translation: 0154ff378b3f4329871378229e269fc7
|
||||
Tz0i8g/message: 8df6777277469c1fd88cc18dde2f1cc3
|
||||
Tz0i8g/origin/0/0: 77c9f6139024b2069e4fdc5120f69cb5
|
||||
Tz0i8g/origin/1/0: 94d3e20c29bb517dd394711d7524e478
|
||||
@@ -2228,10 +2298,17 @@ checksums:
|
||||
n1ekoW/message: ec7b8f314fe9bc6591006707484ede61
|
||||
n1ekoW/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
n1ekoW/translation: ec7b8f314fe9bc6591006707484ede61
|
||||
fcWrnU/translation: 6d842546a84e919c519bf8cbffdf25a0
|
||||
jbGGzf/message: 7f18a6418cc7eb05158cff67247bc966
|
||||
jbGGzf/placeholders/partnerName/0: f182b91b0442585d06f8bf9545912628
|
||||
jbGGzf/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
jbGGzf/translation: 7f18a6418cc7eb05158cff67247bc966
|
||||
4dGN6E/message: dd086c229fd7cc0247d4c1b5c6f096a8
|
||||
4dGN6E/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
4dGN6E/translation: dd086c229fd7cc0247d4c1b5c6f096a8
|
||||
fcWrnU/message: 6d842546a84e919c519bf8cbffdf25a0
|
||||
fcWrnU/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
fcWrnU/origin/1/0: 69d8634b177fdf937764b406fd477547
|
||||
fcWrnU/translation: 6d842546a84e919c519bf8cbffdf25a0
|
||||
mErq7F/message: 0dd2ae69be4618c1f9e615774a4509ca
|
||||
mErq7F/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
mErq7F/translation: 0dd2ae69be4618c1f9e615774a4509ca
|
||||
@@ -2267,9 +2344,15 @@ checksums:
|
||||
wIb7Ng/message: 67659097ef6bcb194839858b2c4f3a3c
|
||||
wIb7Ng/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
wIb7Ng/translation: 67659097ef6bcb194839858b2c4f3a3c
|
||||
6sKjjx/translation: 50861c0881fe87a77412417e5ffb3fbb
|
||||
6sKjjx/message: 50861c0881fe87a77412417e5ffb3fbb
|
||||
6sKjjx/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
6sKjjx/translation: 50861c0881fe87a77412417e5ffb3fbb
|
||||
J9%2BzIR/message: d719cefeec51f2a6dd01064715a1fccc
|
||||
J9%2BzIR/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
J9%2BzIR/translation: d719cefeec51f2a6dd01064715a1fccc
|
||||
r%2BdxEH/message: c3d9e1523d1401870fbf56fa8bce8f8b
|
||||
r%2BdxEH/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
r%2BdxEH/translation: c3d9e1523d1401870fbf56fa8bce8f8b
|
||||
vnS6Rf/message: 003f7f93d44b0f5c2e40bf24e7af3bd7
|
||||
vnS6Rf/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
vnS6Rf/translation: 003f7f93d44b0f5c2e40bf24e7af3bd7
|
||||
@@ -2277,9 +2360,13 @@ checksums:
|
||||
R6pB8R/origin/0/0: 09a6a32f9a6b415e81b4d2f8181a0e6b
|
||||
R6pB8R/translation: e5cb0428f1ac6485688f120d78742875
|
||||
veIDCY/message: 4be2811a8f3455618032ccb59e40658b
|
||||
veIDCY/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
veIDCY/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
veIDCY/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
veIDCY/translation: 4be2811a8f3455618032ccb59e40658b
|
||||
4%2BJaWo/message: e346e4ed7d138dcc873db187922369da
|
||||
4%2BJaWo/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
4%2BJaWo/origin/1/0: 94d3e20c29bb517dd394711d7524e478
|
||||
4%2BJaWo/origin/2/0: 94d3e20c29bb517dd394711d7524e478
|
||||
4%2BJaWo/translation: e346e4ed7d138dcc873db187922369da
|
||||
Y8yzGg/message: 396a94648584067cc5795f7b32d3d663
|
||||
Y8yzGg/origin/0/0: 82a5cf8d69f098641eb357bf102cd504
|
||||
Y8yzGg/translation: 396a94648584067cc5795f7b32d3d663
|
||||
@@ -2309,12 +2396,13 @@ checksums:
|
||||
D%2BNlUC/message: 803281c327a02a2feba182f4a2b38311
|
||||
D%2BNlUC/origin/0/0: 358ea1b5babb2e9ccbf1a4c9f89dd3a5
|
||||
D%2BNlUC/translation: 803281c327a02a2feba182f4a2b38311
|
||||
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
|
||||
KM6m8p/message: c621ea9404a37af289def443b309bf1b
|
||||
KM6m8p/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
|
||||
bff61F/message: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||
bff61F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
bff61F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
bff61F/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
bff61F/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
bff61F/translation: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||
CAL6E9/message: b63448c05270497973ac4407047dae02
|
||||
CAL6E9/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
@@ -2347,6 +2435,14 @@ checksums:
|
||||
NnH3pK/message: 82a46f197779af4dde92e7e5f9336ccf
|
||||
NnH3pK/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
NnH3pK/translation: 82a46f197779af4dde92e7e5f9336ccf
|
||||
InJcdo/message: 1b2364c95fd318d99cc502745beac94a
|
||||
InJcdo/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
InJcdo/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
InJcdo/translation: 1b2364c95fd318d99cc502745beac94a
|
||||
zDNL2V/message: 6e053a87e63372e903c16a06de25c9a3
|
||||
zDNL2V/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
zDNL2V/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
zDNL2V/translation: 6e053a87e63372e903c16a06de25c9a3
|
||||
itgYbC/message: efe14181bc517316e3ab3258d72d2ff9
|
||||
itgYbC/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
itgYbC/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
@@ -2357,6 +2453,9 @@ checksums:
|
||||
nhMhRr/message: 07edd8c50685a52c0969d711df26d768
|
||||
nhMhRr/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
|
||||
nhMhRr/translation: 07edd8c50685a52c0969d711df26d768
|
||||
ssK7k%2B/message: 4321c1f033c350419ad198aaafe3b6fd
|
||||
ssK7k%2B/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
ssK7k%2B/translation: 4321c1f033c350419ad198aaafe3b6fd
|
||||
NcFrgC/message: bd15a7b3d8b4bce18cd42682ea1ec164
|
||||
NcFrgC/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
NcFrgC/translation: bd15a7b3d8b4bce18cd42682ea1ec164
|
||||
@@ -2417,12 +2516,12 @@ checksums:
|
||||
vlxQFL/message: 730f33b8f1fc002c4f393ccd262b6664
|
||||
vlxQFL/origin/0/0: afee530613110e248ffde6b56872ebed
|
||||
vlxQFL/translation: 730f33b8f1fc002c4f393ccd262b6664
|
||||
HQVm6e/translation: ec66b543a593e9dabf47de5979892d8d
|
||||
HQVm6e/message: ec66b543a593e9dabf47de5979892d8d
|
||||
HQVm6e/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
gKmoow/translation: 83997c77cb0dc8c8c90f7ab706aa817f
|
||||
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
|
||||
@@ -2432,6 +2531,9 @@ checksums:
|
||||
mwBdZ2/message: b49224632bd6c3b7f5e462912aeb1081
|
||||
mwBdZ2/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
mwBdZ2/translation: b49224632bd6c3b7f5e462912aeb1081
|
||||
PV7XRK/message: 408b8862ad5ec9027eeec59b129ad18f
|
||||
PV7XRK/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
PV7XRK/translation: 408b8862ad5ec9027eeec59b129ad18f
|
||||
duf3Nr/message: b455329e2a71da677acab91d3a00bad6
|
||||
duf3Nr/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
duf3Nr/translation: b455329e2a71da677acab91d3a00bad6
|
||||
@@ -2441,6 +2543,9 @@ checksums:
|
||||
kp6L3T/message: b7eadb89c615874f416d9658d0428c4c
|
||||
kp6L3T/origin/0/0: 30791e26a172d47d3cb0693e82679246
|
||||
kp6L3T/translation: b7eadb89c615874f416d9658d0428c4c
|
||||
pEb95p/message: 1d4c2a0ec7e4c707a4470fa4262b495d
|
||||
pEb95p/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
pEb95p/translation: 1d4c2a0ec7e4c707a4470fa4262b495d
|
||||
MHrjPM/message: 344e64395eaff6822a57d18623853e1a
|
||||
MHrjPM/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
MHrjPM/translation: 344e64395eaff6822a57d18623853e1a
|
||||
@@ -2488,6 +2593,9 @@ checksums:
|
||||
LeM5RY/message: dfeac280858332082ad34d2623a59863
|
||||
LeM5RY/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
LeM5RY/translation: dfeac280858332082ad34d2623a59863
|
||||
5MPY04/message: 661f2e21a85fbe1281b141a8041c8778
|
||||
5MPY04/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
5MPY04/translation: 661f2e21a85fbe1281b141a8041c8778
|
||||
W1ewR0/message: ddcac5d14d48358b043a9487b42eec16
|
||||
W1ewR0/origin/0/0: fc0c18a095a68277a0f90a115942683b
|
||||
W1ewR0/origin/1/0: 1b05beba6bdd908015fa8e798717f024
|
||||
@@ -2507,6 +2615,9 @@ checksums:
|
||||
YcyP2z/origin/0/0: 3161dd30737e7d849d4ccc30a28bada9
|
||||
YcyP2z/origin/1/0: 3161dd30737e7d849d4ccc30a28bada9
|
||||
YcyP2z/translation: 7495f1434c9188849bffd015811d5337
|
||||
c1vOQR/message: 640ab5162eed616a7235d68376a016ed
|
||||
c1vOQR/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
c1vOQR/translation: 640ab5162eed616a7235d68376a016ed
|
||||
MxQXhL/message: c1c949477e121d73ed6683eddf9cd874
|
||||
MxQXhL/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
MxQXhL/origin/1/0: 69d8634b177fdf937764b406fd477547
|
||||
@@ -2526,8 +2637,12 @@ checksums:
|
||||
2QGEbi/message: 550198b2c87f06726a843c79c1026ed9
|
||||
2QGEbi/origin/0/0: 4b4f55c033add8db69178563832227c6
|
||||
2QGEbi/translation: 550198b2c87f06726a843c79c1026ed9
|
||||
23nvb2/message: b9dedce59b5594ee2a88bf5d55d6dfac
|
||||
23nvb2/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
23nvb2/translation: b9dedce59b5594ee2a88bf5d55d6dfac
|
||||
Oo4E6p/message: af985cf72c47df76e2f2139a427d9948
|
||||
Oo4E6p/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
Oo4E6p/origin/1/0: 1b05beba6bdd908015fa8e798717f024
|
||||
Oo4E6p/translation: af985cf72c47df76e2f2139a427d9948
|
||||
K7k9u3/message: 1cf9d227042f6a65adba38cb7296830d
|
||||
K7k9u3/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
|
||||
@@ -2544,9 +2659,13 @@ checksums:
|
||||
A8jqN7/message: 656c93265d7e2bef1245b17d85f85ae5
|
||||
A8jqN7/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
|
||||
A8jqN7/translation: 656c93265d7e2bef1245b17d85f85ae5
|
||||
Q60WUZ/translation: 4284c33aed2f2c5e004ed0a7d7d9bf87
|
||||
Q60WUZ/message: 4284c33aed2f2c5e004ed0a7d7d9bf87
|
||||
Q60WUZ/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
Q60WUZ/translation: 4284c33aed2f2c5e004ed0a7d7d9bf87
|
||||
KNK33q/message: a606d59661b59b5e2e9ab58e8cbc143b
|
||||
KNK33q/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
KNK33q/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
KNK33q/translation: a606d59661b59b5e2e9ab58e8cbc143b
|
||||
T7Mywq/message: 62899897d7bffcb1df3af77d460f0881
|
||||
T7Mywq/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
T7Mywq/translation: 62899897d7bffcb1df3af77d460f0881
|
||||
@@ -2589,6 +2708,9 @@ checksums:
|
||||
TFv5tM/message: 4f2240aeff6f7275feb33ca3f608e48c
|
||||
TFv5tM/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
TFv5tM/translation: 4f2240aeff6f7275feb33ca3f608e48c
|
||||
V1o9Jo/message: 7b212f1c2fdc370974f859b08349b0c6
|
||||
V1o9Jo/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
V1o9Jo/translation: 7b212f1c2fdc370974f859b08349b0c6
|
||||
It4%2FFS/message: ae1527c2765592f13980acb66997e570
|
||||
It4%2FFS/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
It4%2FFS/translation: ae1527c2765592f13980acb66997e570
|
||||
@@ -2632,16 +2754,21 @@ checksums:
|
||||
7Ufoyg/message: 5c418701b27b9acbc5dd199719007362
|
||||
7Ufoyg/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
7Ufoyg/translation: 5c418701b27b9acbc5dd199719007362
|
||||
uXKtPH/message: 8a95d730b360db6041001450aa751ab4
|
||||
uXKtPH/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
|
||||
uXKtPH/translation: 8a95d730b360db6041001450aa751ab4
|
||||
mx8%2B1u/message: f2949ca2dc18b0063af92e60779abb65
|
||||
mx8%2B1u/origin/0/0: 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/translation: acb1fdc14ed875815ae9f8a5254bc634
|
||||
8AwlaR/message: acb1fdc14ed875815ae9f8a5254bc634
|
||||
8AwlaR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
8AwlaR/translation: acb1fdc14ed875815ae9f8a5254bc634
|
||||
i5yNAO/message: 478092018a27a4e588cb5c70968ef11a
|
||||
i5yNAO/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
i5yNAO/translation: 478092018a27a4e588cb5c70968ef11a
|
||||
Ws%2B3X%2B/message: 905b10eeb09fdc5ac7fc757e935ee757
|
||||
Ws%2B3X%2B/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
Ws%2B3X%2B/translation: 905b10eeb09fdc5ac7fc757e935ee757
|
||||
@@ -2675,14 +2802,15 @@ checksums:
|
||||
zERArS/placeholders/0/0: 06f23520ebabfffed4999b5bcf61c9cd
|
||||
zERArS/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
zERArS/translation: aca196d729401d8adc5c8ca9d558840e
|
||||
kwkhPe/message: 63c3b52882e0d779859307d672c178c2
|
||||
kwkhPe/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
kwkhPe/origin/1/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
kwkhPe/translation: 63c3b52882e0d779859307d672c178c2
|
||||
IelE1h/message: 972773025e763ddf53273df6d37a729a
|
||||
IelE1h/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
IelE1h/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
IelE1h/origin/2/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
IelE1h/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
IelE1h/translation: 972773025e763ddf53273df6d37a729a
|
||||
cWtfn1/message: 9a91ea70f5eb55aac96640bf0fd7feff
|
||||
cWtfn1/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
cWtfn1/origin/1/0: 69d8634b177fdf937764b406fd477547
|
||||
cWtfn1/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
cWtfn1/translation: 9a91ea70f5eb55aac96640bf0fd7feff
|
||||
b3Thhd/message: 5b7b755f05330164dbfb00096c500ee3
|
||||
b3Thhd/origin/0/0: 12c1fab543c8848bcb10107d47336736
|
||||
@@ -2752,12 +2880,22 @@ checksums:
|
||||
"%2BEkBs0/message": 69d1d2575f2e73b08ac622bde0a45b4d
|
||||
"%2BEkBs0/origin/0/0": d1e1d7ccce60e05570bedfbb93fc9416
|
||||
"%2BEkBs0/translation": 69d1d2575f2e73b08ac622bde0a45b4d
|
||||
7sdSkl/message: 6132de0281f288a30690bb2218089951
|
||||
7sdSkl/placeholders/magicLinkRecipient/0: 63ab7807b81db57469a3b7fa40e6826e
|
||||
7sdSkl/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
7sdSkl/translation: 6132de0281f288a30690bb2218089951
|
||||
9y1RcT/message: 2dfa121e0ff1914e08068308a81973c0
|
||||
9y1RcT/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
9y1RcT/translation: 2dfa121e0ff1914e08068308a81973c0
|
||||
an6ayw/message: 2d39f6440ad379e417193dbfaa265b5f
|
||||
an6ayw/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
an6ayw/translation: 2d39f6440ad379e417193dbfaa265b5f
|
||||
GdWB%2BV/message: 6a15a9b926ecd2c91454e9da19c6049e
|
||||
GdWB%2BV/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
GdWB%2BV/translation: 6a15a9b926ecd2c91454e9da19c6049e
|
||||
LnaC5y/message: 229d2a9159e17f243cf317b9bd7d471c
|
||||
LnaC5y/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
LnaC5y/translation: 229d2a9159e17f243cf317b9bd7d471c
|
||||
2X4ecw/message: fcefd247ec76a372002d2cffac3c5b0f
|
||||
2X4ecw/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
2X4ecw/translation: fcefd247ec76a372002d2cffac3c5b0f
|
||||
@@ -2771,6 +2909,9 @@ checksums:
|
||||
8iP9oQ/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
8iP9oQ/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
8iP9oQ/translation: 5c07446c838d07572c6b40621507e951
|
||||
kxZ7LS/message: 41ebcdff41ce85329915754faf7140c0
|
||||
kxZ7LS/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
kxZ7LS/translation: 41ebcdff41ce85329915754faf7140c0
|
||||
3d54Wj/message: d67dc42810694fd1344a9a9545aca1e7
|
||||
3d54Wj/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
3d54Wj/translation: d67dc42810694fd1344a9a9545aca1e7
|
||||
@@ -2877,9 +3018,9 @@ checksums:
|
||||
RtlIYB/message: df4783d593505766d78c5bc08d3d48ac
|
||||
RtlIYB/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
RtlIYB/translation: df4783d593505766d78c5bc08d3d48ac
|
||||
3WXdoZ/translation: 4cbeab40f77975492a23eec428732440
|
||||
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
|
||||
@@ -2914,18 +3055,28 @@ checksums:
|
||||
MdN5zD/message: e2e816f2b7a13b1056d79e7f25088664
|
||||
MdN5zD/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
MdN5zD/translation: e2e816f2b7a13b1056d79e7f25088664
|
||||
ZhQoZn/message: b05a1191bf048d76e7d6c1c3e76ce1fd
|
||||
ZhQoZn/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
ZhQoZn/translation: b05a1191bf048d76e7d6c1c3e76ce1fd
|
||||
2SePRT/message: 257b840726f972f384243a72767f880f
|
||||
2SePRT/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
2SePRT/translation: 257b840726f972f384243a72767f880f
|
||||
uOqaMC/message: 24fc6cdc8740f37a83df85f582f03293
|
||||
uOqaMC/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
uOqaMC/translation: 24fc6cdc8740f37a83df85f582f03293
|
||||
QriiAI/message: 37a6b38e70fdfef1e2208ede67aeab98
|
||||
QriiAI/placeholders/0/0: 4ab04dd216aebb6485be0c4b94d5ca6b
|
||||
QriiAI/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
QriiAI/translation: 37a6b38e70fdfef1e2208ede67aeab98
|
||||
8Zjc7q/message: 65609a209e74f230563a7c8d7258a83c
|
||||
8Zjc7q/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
8Zjc7q/translation: 65609a209e74f230563a7c8d7258a83c
|
||||
GoDLB9/message: 8c8d944e07388c5877effdb2c2803dcf
|
||||
GoDLB9/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
GoDLB9/translation: 8c8d944e07388c5877effdb2c2803dcf
|
||||
c4nMcR/translation: bf4f697079eef51c3ebe46ce79063895
|
||||
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
|
||||
@@ -2944,9 +3095,18 @@ checksums:
|
||||
PELbXB/message: 6984aed03bbcd94041c5dd20f62e56ed
|
||||
PELbXB/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
PELbXB/translation: 6984aed03bbcd94041c5dd20f62e56ed
|
||||
ygpeZ8/message: 59444b15ae58f987931a4746f4c77a0b
|
||||
ygpeZ8/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
ygpeZ8/translation: 59444b15ae58f987931a4746f4c77a0b
|
||||
"%2BA6Wuq/message": 36d33d3622921dbb1475da2acb98f754
|
||||
"%2BA6Wuq/origin/0/0": 278a40eb45b1219f424703d0abc1d273
|
||||
"%2BA6Wuq/translation": 36d33d3622921dbb1475da2acb98f754
|
||||
Ozt2vv/message: 8f730f68d52b7efa727aa0784282ebcb
|
||||
Ozt2vv/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
Ozt2vv/translation: 8f730f68d52b7efa727aa0784282ebcb
|
||||
EuK5ez/message: d262a1ba9ebeea549d982f9cf85a6c18
|
||||
EuK5ez/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
EuK5ez/translation: d262a1ba9ebeea549d982f9cf85a6c18
|
||||
"%2FtOdd2/message": 099e1780348cd7bf617721344371a430
|
||||
"%2FtOdd2/origin/0/0": 03c00909b41be01231cd3e843c8ee44e
|
||||
"%2FtOdd2/translation": 099e1780348cd7bf617721344371a430
|
||||
@@ -2962,12 +3122,12 @@ checksums:
|
||||
25fAUC/message: 3e4cce96bc1197a1caa2b7fb4d3c7e53
|
||||
25fAUC/origin/0/0: d1e1d7ccce60e05570bedfbb93fc9416
|
||||
25fAUC/translation: 3e4cce96bc1197a1caa2b7fb4d3c7e53
|
||||
GRAGsB/translation: 751a534fd6e62a92ff64c46894c7db86
|
||||
GRAGsB/message: 751a534fd6e62a92ff64c46894c7db86
|
||||
GRAGsB/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
j0o6ml/translation: 1e036ffca9d9cb9e2941b83fbbf375c0
|
||||
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
|
||||
@@ -2980,10 +3140,10 @@ checksums:
|
||||
Hg1qE9/message: c808949b9b2b4a9aba2472f5d1050167
|
||||
Hg1qE9/origin/0/0: 30791e26a172d47d3cb0693e82679246
|
||||
Hg1qE9/translation: c808949b9b2b4a9aba2472f5d1050167
|
||||
OTxjpR/translation: 370a32c49489db61978ac91b55699c24
|
||||
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
|
||||
|
||||
@@ -46,6 +46,7 @@ interface FormValues {
|
||||
interface AuthProps {
|
||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||
isSignUp?: boolean;
|
||||
callbackURL?: string;
|
||||
}
|
||||
|
||||
const EmailSchema = z.object({
|
||||
@@ -152,7 +153,11 @@ const availableSocialProviders = {
|
||||
},
|
||||
};
|
||||
|
||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
export function Auth({
|
||||
setIsMagicLinkSent,
|
||||
isSignUp,
|
||||
callbackURL: callbackURLProp,
|
||||
}: AuthProps) {
|
||||
const [isCloudEnv, setIsCloudEnv] = useState(false);
|
||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||
useState<null | AuthProvider>(null);
|
||||
@@ -165,7 +170,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const redirect = useSearchParams().get("next");
|
||||
const callbackURL = redirect ?? "/boards";
|
||||
const callbackURL = callbackURLProp ?? redirect ?? "/boards";
|
||||
|
||||
// Safely get environment variables on client side to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
|
||||
@@ -33,6 +33,7 @@ type SearchResult =
|
||||
boardPublicId: string;
|
||||
boardName: string;
|
||||
listName: string;
|
||||
cardNumber: number | null;
|
||||
updatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
type: "card";
|
||||
@@ -159,8 +160,17 @@ export default function CommandPallette({
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
||||
{result.title}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.type === "card" &&
|
||||
result.cardNumber != null &&
|
||||
workspace.cardPrefix && (
|
||||
<span className="flex-shrink-0 text-xs text-light-600 dark:text-dark-600">
|
||||
{workspace.cardPrefix}-{result.cardNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result.type === "card" && (
|
||||
<div className="truncate text-xs text-light-700 dark:text-dark-700">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
@@ -13,8 +14,12 @@ import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
|
||||
import Button from "./Button";
|
||||
import Modal from "./modal";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -43,9 +48,11 @@ export default function Dashboard({
|
||||
hasRightPanel = false,
|
||||
}: DashboardProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { openModal } = useModal();
|
||||
const { openModal, closeModal, modalContentType } = useModal();
|
||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
||||
@@ -99,16 +106,67 @@ export default function Dashboard({
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const partnerActivated = searchParams.get("partner_activated");
|
||||
const partnerError = searchParams.get("partner_error");
|
||||
|
||||
if (partnerActivated) {
|
||||
showPopup({
|
||||
header: t`License activated`,
|
||||
message: t`Your license has been activated successfully.`,
|
||||
icon: "success",
|
||||
});
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("partner_activated");
|
||||
router.replace(`?${params.toString()}`);
|
||||
} else if (partnerError) {
|
||||
const messages: Record<string, string> = {
|
||||
invalid_license: t`That license key could not be found. Please contact support.`,
|
||||
license_inactive: t`Your license is not active. Please check your account.`,
|
||||
missing_license: t`No license key was provided. Please try activating again.`,
|
||||
};
|
||||
showPopup({
|
||||
header: t`License activation failed`,
|
||||
message:
|
||||
messages[partnerError] ??
|
||||
t`Something went wrong during license activation.`,
|
||||
icon: "error",
|
||||
});
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("partner_error");
|
||||
router.replace(`?${params.toString()}`);
|
||||
}
|
||||
}, [searchParams, showPopup, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoaded && availableWorkspaces.length === 0) {
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||
router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`);
|
||||
router.push(
|
||||
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||
);
|
||||
} else {
|
||||
openModal("NEW_WORKSPACE", undefined, undefined, false);
|
||||
}
|
||||
}
|
||||
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const isCredentialsEnabled =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
|
||||
if (
|
||||
!userLoading &&
|
||||
user &&
|
||||
isCredentialsEnabled &&
|
||||
user.hasMagicLinkAccount &&
|
||||
!user.hasPassword &&
|
||||
!sessionStorage.getItem("set_password_prompted")
|
||||
) {
|
||||
sessionStorage.setItem("set_password_prompted", "1");
|
||||
openModal("SET_PASSWORD_PROMPT");
|
||||
}
|
||||
}, [user, userLoading, openModal]);
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
@@ -203,6 +261,30 @@ export default function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={modalContentType === "SET_PASSWORD_PROMPT"}
|
||||
>
|
||||
{user?.hasPassword ? (
|
||||
<div className="p-5">
|
||||
<h2 className="pb-4 text-base font-medium dark:text-white">{t`Password already set`}</h2>
|
||||
<p className="mb-6 text-sm text-light-900">
|
||||
{t`Your account already has a password. You can change it from your account settings.`}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={closeModal}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{t`Close`}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ChangePasswordFormConfirmation hasPassword={false} />
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -474,7 +474,7 @@ export default function Editor({
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
Markdown,
|
||||
Markdown.configure({ transformPastedText: true }),
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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({
|
||||
@@ -110,46 +96,11 @@ export function NewWorkspaceForm() {
|
||||
description: values.description,
|
||||
slug: values.slug,
|
||||
plan: values.plan,
|
||||
cardPrefix: values.cardPrefix,
|
||||
role: "admin",
|
||||
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 +119,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 +129,7 @@ export function NewWorkspaceForm() {
|
||||
|
||||
createWorkspace.mutate({
|
||||
name: values.name,
|
||||
slug: !isCloudEnv && values.slug ? values.slug : undefined,
|
||||
slug: values.slug,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -290,11 +192,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 +209,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"
|
||||
|
||||
@@ -28,7 +28,6 @@ import ButtonComponent from "~/components/Button";
|
||||
import ReactiveButton from "~/components/ReactiveButton";
|
||||
import UserMenu from "~/components/UserMenu";
|
||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
@@ -53,7 +52,6 @@ export default function SideNavigation({
|
||||
const { workspace } = useWorkspace();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isInitialised, setIsInitialised] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
@@ -223,21 +221,19 @@ export default function SideNavigation({
|
||||
<ButtonComponent
|
||||
iconLeft={<HiBolt />}
|
||||
variant="secondary"
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
aria-label="Upgrade to Pro"
|
||||
title="Upgrade to Pro"
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
|
||||
aria-label={t`Start free trial`}
|
||||
title={t`Start free trial`}
|
||||
iconOnly
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
/>
|
||||
) : (
|
||||
<ButtonComponent
|
||||
iconLeft={<HiBolt />}
|
||||
fullWidth
|
||||
variant="secondary"
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
|
||||
>
|
||||
{t`Upgrade to Pro`}
|
||||
{t`Start free trial`}
|
||||
</ButtonComponent>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { env } from "next-runtime-env";
|
||||
import { Fragment, useState } from "react";
|
||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||
@@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import CommandPallette from "./CommandPallette";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
@@ -20,6 +21,8 @@ export default function WorkspaceMenu({
|
||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||
useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const { data: hasPartnerSlot } =
|
||||
api.workspace.hasAvailablePartnerSlot.useQuery();
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
@@ -150,11 +153,19 @@ export default function WorkspaceMenu({
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() =>
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`)
|
||||
: openModal("NEW_WORKSPACE")
|
||||
}
|
||||
onClick={() => {
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||
openModal("NEW_WORKSPACE");
|
||||
} else if (hasPartnerSlot) {
|
||||
router.push(
|
||||
`/onboarding/workspace?partner=1&returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||
);
|
||||
} else {
|
||||
router.push(
|
||||
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Create workspace`}
|
||||
|
||||
@@ -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
20
apps/web/src/pages/api/partner/_utils.ts
Normal file
20
apps/web/src/pages/api/partner/_utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type WorkspacePlan = "free" | "team" | "pro" | "enterprise";
|
||||
|
||||
export interface TierConfig {
|
||||
plan: WorkspacePlan;
|
||||
seats: number | null;
|
||||
unlimitedSeats: boolean;
|
||||
workspaceSlots: number;
|
||||
}
|
||||
|
||||
const TIER_MAP: Record<number, TierConfig> = {
|
||||
1: { plan: "team", seats: 5, unlimitedSeats: false, workspaceSlots: 1 },
|
||||
2: { plan: "pro", seats: 15, unlimitedSeats: false, workspaceSlots: 2 },
|
||||
3: { plan: "pro", seats: null, unlimitedSeats: true, workspaceSlots: 4 },
|
||||
};
|
||||
|
||||
export function tierConfig(tier: number): TierConfig {
|
||||
const config = TIER_MAP[tier];
|
||||
if (!config) throw new Error(`Unknown partner tier: ${tier}`);
|
||||
return config;
|
||||
}
|
||||
144
apps/web/src/pages/api/partner/callback.ts
Normal file
144
apps/web/src/pages/api/partner/callback.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
import { tierConfig } from "./_utils";
|
||||
|
||||
const log = createLogger("api");
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
interface OAuthLicenseResponse {
|
||||
license_key: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface LicenseDetailResponse {
|
||||
license_key: string;
|
||||
status: string;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
async function exchangeCodeForToken(code: string): Promise<TokenResponse> {
|
||||
const tokenUrl = process.env.PARTNER_TOKEN_URL;
|
||||
const clientId = process.env.PARTNER_CLIENT_ID;
|
||||
const clientSecret = process.env.PARTNER_CLIENT_SECRET;
|
||||
const redirectUrl = process.env.PARTNER_REDIRECT_URL;
|
||||
if (!tokenUrl) throw new Error("PARTNER_TOKEN_URL not configured");
|
||||
if (!clientId) throw new Error("PARTNER_CLIENT_ID not configured");
|
||||
if (!clientSecret) throw new Error("PARTNER_CLIENT_SECRET not configured");
|
||||
if (!redirectUrl) throw new Error("PARTNER_REDIRECT_URL not configured");
|
||||
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
|
||||
return res.json() as Promise<TokenResponse>;
|
||||
}
|
||||
|
||||
async function fetchOAuthLicense(
|
||||
accessToken: string,
|
||||
): Promise<OAuthLicenseResponse> {
|
||||
const oauthLicenseUrl = process.env.PARTNER_OAUTH_LICENSE_URL;
|
||||
if (!oauthLicenseUrl)
|
||||
throw new Error("PARTNER_OAUTH_LICENSE_URL not configured");
|
||||
|
||||
const res = await fetch(`${oauthLicenseUrl}?access_token=${accessToken}`);
|
||||
if (!res.ok) throw new Error(`OAuth license fetch failed: ${res.status}`);
|
||||
return res.json() as Promise<OAuthLicenseResponse>;
|
||||
}
|
||||
|
||||
async function fetchLicenseDetail(
|
||||
licenseKey: string,
|
||||
): Promise<LicenseDetailResponse> {
|
||||
const licenseApiUrl = process.env.PARTNER_LICENSE_API_URL;
|
||||
const apiKey = process.env.PARTNER_API_KEY;
|
||||
if (!licenseApiUrl) throw new Error("PARTNER_LICENSE_API_URL not configured");
|
||||
if (!apiKey) throw new Error("PARTNER_API_KEY not configured");
|
||||
|
||||
const apiKeyHeader = process.env.PARTNER_API_KEY_HEADER;
|
||||
if (!apiKeyHeader) throw new Error("PARTNER_API_KEY_HEADER not configured");
|
||||
|
||||
const res = await fetch(`${licenseApiUrl}/${licenseKey}`, {
|
||||
headers: { [apiKeyHeader]: apiKey },
|
||||
});
|
||||
if (!res.ok) throw new Error(`License detail fetch failed: ${res.status}`);
|
||||
return res.json() as Promise<LicenseDetailResponse>;
|
||||
}
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 20, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { code } = req.query;
|
||||
|
||||
if (!code || typeof code !== "string") {
|
||||
return res.status(200).json({ message: "OK" });
|
||||
}
|
||||
|
||||
let license: LicenseDetailResponse;
|
||||
|
||||
try {
|
||||
const tokenData = await exchangeCodeForToken(code);
|
||||
const oauthLicense = await fetchOAuthLicense(tokenData.access_token);
|
||||
license = await fetchLicenseDetail(oauthLicense.license_key);
|
||||
} catch (err) {
|
||||
log.error({ err }, "Partner OAuth flow failed");
|
||||
return res.redirect(`/partner/activate?error=oauth_failed`);
|
||||
}
|
||||
|
||||
const { db, user } = await createNextApiContext(req);
|
||||
|
||||
const cfg = tierConfig(license.tier);
|
||||
const status = license.status === "active" ? "active" : "inactive";
|
||||
|
||||
// Ensure subscription slots exist — webhook may have already created them
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license.license_key,
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
license.license_key,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: license.tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
cfg.workspaceSlots,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.redirect(
|
||||
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.redirect(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(license.license_key)}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
98
apps/web/src/pages/api/partner/link.ts
Normal file
98
apps/web/src/pages/api/partner/link.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 20, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { license_key } = req.query;
|
||||
|
||||
if (!license_key || typeof license_key !== "string") {
|
||||
return res.redirect("/boards?partner_error=missing_license");
|
||||
}
|
||||
|
||||
const { db, user } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.redirect(
|
||||
`/login?next=${encodeURIComponent(`/api/partner/link?license_key=${license_key}`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
|
||||
if (!allSlots.length) {
|
||||
return res.redirect("/boards?partner_error=invalid_license");
|
||||
}
|
||||
|
||||
const activeSlots = allSlots.filter((s) =>
|
||||
["active", "trialing"].includes(s.status),
|
||||
);
|
||||
|
||||
if (!activeSlots.length) {
|
||||
return res.redirect("/boards?partner_error=license_inactive");
|
||||
}
|
||||
|
||||
const unlinkedSlot = activeSlots.find((s) => !s.referenceId);
|
||||
|
||||
if (!unlinkedSlot) {
|
||||
return res.redirect("/boards?partner_activated=1");
|
||||
}
|
||||
|
||||
const linkedIds = new Set(
|
||||
activeSlots.filter((s) => s.referenceId).map((s) => s.referenceId!),
|
||||
);
|
||||
|
||||
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
const availableWorkspace = memberships
|
||||
.map((m) => m.workspace)
|
||||
.find((w) => w && !w.deletedAt && !linkedIds.has(w.publicId));
|
||||
|
||||
if (!availableWorkspace) {
|
||||
return res.redirect(
|
||||
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await subscriptionRepo.updateById(db, unlinkedSlot.id, {
|
||||
referenceId: availableWorkspace.publicId,
|
||||
});
|
||||
|
||||
await workspaceRepo.update(db, availableWorkspace.publicId, {
|
||||
plan: unlinkedSlot.plan as "free" | "team" | "pro" | "enterprise",
|
||||
});
|
||||
|
||||
const remainingUnlinked = activeSlots.filter(
|
||||
(s) => !s.referenceId && s.id !== unlinkedSlot.id,
|
||||
);
|
||||
|
||||
if (remainingUnlinked.length > 0) {
|
||||
const updatedLinkedIds = new Set([
|
||||
...linkedIds,
|
||||
availableWorkspace.publicId,
|
||||
]);
|
||||
const hasMoreAvailableWorkspace = memberships
|
||||
.map((m) => m.workspace)
|
||||
.some((w) => w && !w.deletedAt && !updatedLinkedIds.has(w.publicId));
|
||||
|
||||
if (hasMoreAvailableWorkspace) {
|
||||
return res.redirect(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(license_key)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.redirect("/boards?partner_activated=1");
|
||||
}),
|
||||
);
|
||||
418
apps/web/src/pages/api/partner/webhook.ts
Normal file
418
apps/web/src/pages/api/partner/webhook.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { cancelWorkspaceAccess } from "@kan/api/utils/workspace";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
import { getActiveSubscriptions } from "@kan/shared/utils";
|
||||
|
||||
import type { TierConfig } from "./_utils";
|
||||
import { tierConfig } from "./_utils";
|
||||
|
||||
const log = createLogger("api");
|
||||
|
||||
async function createAndLinkSlots(
|
||||
db: dbClient,
|
||||
licenseKey: string,
|
||||
cfg: TierConfig,
|
||||
tier: number,
|
||||
alreadyLinkedReferenceIds: Set<string>,
|
||||
count: number,
|
||||
) {
|
||||
const linkedId = [...alreadyLinkedReferenceIds][0];
|
||||
let autoLinked = 0;
|
||||
|
||||
if (linkedId) {
|
||||
const linkedWorkspace = await workspaceRepo.getByPublicId(db, linkedId);
|
||||
|
||||
if (linkedWorkspace?.createdBy) {
|
||||
const owned = await workspaceRepo.getAllOwnedByUserId(
|
||||
db,
|
||||
linkedWorkspace.createdBy,
|
||||
);
|
||||
|
||||
const candidates = owned.filter(
|
||||
(w) => !alreadyLinkedReferenceIds.has(w.publicId),
|
||||
);
|
||||
|
||||
if (candidates.length > 0) {
|
||||
const existingSubs =
|
||||
await subscriptionRepo.getAllActivePartnerSubsByWorkspaceIds(
|
||||
db,
|
||||
candidates.map((w) => w.publicId),
|
||||
);
|
||||
const alreadySubscribed = new Set(
|
||||
existingSubs
|
||||
.map((s) => s.referenceId)
|
||||
.filter((id): id is string => !!id),
|
||||
);
|
||||
|
||||
const available = candidates
|
||||
.filter((w) => !alreadySubscribed.has(w.publicId))
|
||||
.slice(0, count);
|
||||
|
||||
if (available.length > 0) {
|
||||
const newSlots = await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
licenseKey,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
available.length,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
available.map((workspace, i) => {
|
||||
const slot = newSlots[i];
|
||||
if (!slot) return;
|
||||
return Promise.all([
|
||||
subscriptionRepo.updateById(db, slot.id, {
|
||||
referenceId: workspace.publicId,
|
||||
}),
|
||||
workspaceRepo.update(db, workspace.publicId, {
|
||||
plan: cfg.plan,
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
);
|
||||
|
||||
autoLinked = available.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = count - autoLinked;
|
||||
if (remaining > 0) {
|
||||
await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
licenseKey,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
remaining,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function buffer(readable: Readable) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of readable) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function verifySignature(
|
||||
rawBody: string,
|
||||
signature: string,
|
||||
timestamp: string,
|
||||
): boolean {
|
||||
const secret = process.env.PARTNER_API_KEY;
|
||||
if (!secret) return false;
|
||||
|
||||
const ts = Number(timestamp);
|
||||
if (isNaN(ts) || Date.now() - ts > 300_000) return false;
|
||||
|
||||
const payload = `${timestamp}${rawBody}`;
|
||||
const expected = createHmac("sha256", secret).update(payload).digest("hex");
|
||||
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasReferenceId<T extends { referenceId: string | null | undefined }>(
|
||||
s: T,
|
||||
): s is T & { referenceId: string } {
|
||||
return !!s.referenceId;
|
||||
}
|
||||
|
||||
interface WebhookPayload {
|
||||
event:
|
||||
| "purchase"
|
||||
| "activate"
|
||||
| "deactivate"
|
||||
| "upgrade"
|
||||
| "downgrade"
|
||||
| "migrate";
|
||||
license_key: string;
|
||||
license_status: string;
|
||||
tier: number;
|
||||
prev_license_key?: string;
|
||||
}
|
||||
|
||||
export default withApiLogging(
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const signatureHeader = process.env.PARTNER_SIGNATURE_HEADER;
|
||||
const timestampHeader = process.env.PARTNER_TIMESTAMP_HEADER;
|
||||
|
||||
if (!signatureHeader || !timestampHeader) {
|
||||
log.error("Webhook signature headers not configured");
|
||||
return res.status(500).json({ message: "Server misconfiguration" });
|
||||
}
|
||||
|
||||
const signature = req.headers[signatureHeader] as string | undefined;
|
||||
const timestamp = req.headers[timestampHeader] as string | undefined;
|
||||
|
||||
if (!signature || !timestamp) {
|
||||
return res.status(400).json({ message: "Missing signature headers" });
|
||||
}
|
||||
|
||||
const buf = await buffer(req as unknown as Readable);
|
||||
const rawBody = buf.toString("utf8");
|
||||
|
||||
if (!verifySignature(rawBody, signature, timestamp)) {
|
||||
return res.status(401).json({ message: "Invalid signature" });
|
||||
}
|
||||
|
||||
const payload = JSON.parse(rawBody) as WebhookPayload;
|
||||
const { event, license_key, license_status, tier, prev_license_key } =
|
||||
payload;
|
||||
|
||||
log.info({ headers: req.headers, payload }, "partner webhook received");
|
||||
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
switch (event) {
|
||||
case "purchase":
|
||||
case "activate": {
|
||||
const cfg = tierConfig(tier);
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
const status = event === "activate" ? "active" : license_status;
|
||||
|
||||
if (existing.length === 0) {
|
||||
await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
license_key,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
cfg.workspaceSlots,
|
||||
);
|
||||
} else {
|
||||
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "deactivate": {
|
||||
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
allSlots.filter(hasReferenceId).map(async (slot) => {
|
||||
const siblingSubs = await subscriptionRepo.getByReferenceId(
|
||||
db,
|
||||
slot.referenceId,
|
||||
);
|
||||
const hasOtherActiveSub = getActiveSubscriptions(siblingSubs).some(
|
||||
(s) => s.id !== slot.id,
|
||||
);
|
||||
if (!hasOtherActiveSub) {
|
||||
await cancelWorkspaceAccess(db, slot.referenceId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
|
||||
plan: "free",
|
||||
status: "canceled",
|
||||
unlimitedSeats: false,
|
||||
seats: null,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "upgrade":
|
||||
case "downgrade": {
|
||||
const lookupKey = prev_license_key ?? license_key;
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
lookupKey,
|
||||
);
|
||||
|
||||
if (existing.length === 0) break;
|
||||
|
||||
const cfg = tierConfig(tier);
|
||||
const newCount = cfg.workspaceSlots;
|
||||
|
||||
// Prefer keeping linked slots; among linked, keep in insertion order (LIFO removal)
|
||||
const preferKeep = [
|
||||
...existing.filter((s) => s.referenceId),
|
||||
...existing.filter((s) => !s.referenceId),
|
||||
];
|
||||
|
||||
const slotsToKeep = preferKeep.slice(0, newCount);
|
||||
const slotsToRemove = preferKeep.slice(newCount);
|
||||
|
||||
await Promise.all([
|
||||
...slotsToKeep.map((slot) =>
|
||||
subscriptionRepo.updateById(db, slot.id, {
|
||||
plan: cfg.plan,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
partnerTier: tier,
|
||||
status: "active",
|
||||
...(prev_license_key ? { partnerLicenseKey: license_key } : {}),
|
||||
}),
|
||||
),
|
||||
...slotsToKeep
|
||||
.filter(hasReferenceId)
|
||||
.map((s) =>
|
||||
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
|
||||
),
|
||||
]);
|
||||
|
||||
if (newCount > existing.length) {
|
||||
const linkedIds = new Set(
|
||||
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
|
||||
);
|
||||
await createAndLinkSlots(
|
||||
db,
|
||||
license_key,
|
||||
cfg,
|
||||
tier,
|
||||
linkedIds,
|
||||
newCount - existing.length,
|
||||
);
|
||||
}
|
||||
|
||||
if (slotsToRemove.length > 0) {
|
||||
await Promise.all([
|
||||
...slotsToRemove
|
||||
.filter(hasReferenceId)
|
||||
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
|
||||
...slotsToRemove.map((s) =>
|
||||
subscriptionRepo.updateById(db, s.id, {
|
||||
plan: "free",
|
||||
status: "inactive",
|
||||
unlimitedSeats: false,
|
||||
seats: null,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "migrate": {
|
||||
if (!prev_license_key) break;
|
||||
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
prev_license_key,
|
||||
);
|
||||
if (existing.length === 0) break;
|
||||
|
||||
const cfg = tierConfig(tier);
|
||||
const newCount = cfg.workspaceSlots;
|
||||
|
||||
const preferKeep = [
|
||||
...existing.filter((s) => s.referenceId),
|
||||
...existing.filter((s) => !s.referenceId),
|
||||
];
|
||||
|
||||
const slotsToKeep = preferKeep.slice(0, newCount);
|
||||
const slotsToRemove = preferKeep.slice(newCount);
|
||||
|
||||
await Promise.all([
|
||||
...slotsToKeep.map((slot) =>
|
||||
subscriptionRepo.updateById(db, slot.id, {
|
||||
partnerLicenseKey: license_key,
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
}),
|
||||
),
|
||||
...slotsToKeep
|
||||
.filter(hasReferenceId)
|
||||
.map((s) =>
|
||||
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
|
||||
),
|
||||
]);
|
||||
|
||||
if (newCount > existing.length) {
|
||||
const linkedIds = new Set(
|
||||
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
|
||||
);
|
||||
await createAndLinkSlots(
|
||||
db,
|
||||
license_key,
|
||||
cfg,
|
||||
tier,
|
||||
linkedIds,
|
||||
newCount - existing.length,
|
||||
);
|
||||
}
|
||||
|
||||
if (slotsToRemove.length > 0) {
|
||||
await Promise.all([
|
||||
...slotsToRemove
|
||||
.filter(hasReferenceId)
|
||||
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
|
||||
...slotsToRemove.map((s) =>
|
||||
subscriptionRepo.updateById(db, s.id, {
|
||||
plan: "free",
|
||||
status: "inactive",
|
||||
unlimitedSeats: false,
|
||||
seats: null,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
log.warn({ event }, "Unhandled partner webhook event");
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true, event });
|
||||
},
|
||||
);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -64,7 +64,10 @@ export default async function handler(
|
||||
meta.userId &&
|
||||
meta.userEmail
|
||||
) {
|
||||
const existing = await workspaceRepo.getByPublicId(db, meta.workspacePublicId);
|
||||
const existing = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
meta.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
const slug = meta.workspaceSlug ?? meta.workspacePublicId;
|
||||
@@ -90,10 +93,10 @@ export default async function handler(
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Existing workspace upgrade — update plan (and slug for pro)
|
||||
await workspaceRepo.update(db, meta.workspacePublicId, {
|
||||
plan,
|
||||
...(plan === "pro" && meta.workspaceSlug && { slug: meta.workspaceSlug }),
|
||||
...(plan === "pro" &&
|
||||
meta.workspaceSlug && { slug: meta.workspaceSlug }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -67,8 +67,15 @@ export default withRateLimit(
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const originalFilenameHeader =
|
||||
const rawFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
const originalFilenameHeader = (() => {
|
||||
try {
|
||||
return decodeURIComponent(rawFilenameHeader);
|
||||
} catch {
|
||||
return rawFilenameHeader;
|
||||
}
|
||||
})();
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
|
||||
@@ -64,8 +64,15 @@ export default withRateLimit(
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const originalFilenameHeader =
|
||||
const rawFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
const originalFilenameHeader = (() => {
|
||||
try {
|
||||
return decodeURIComponent(rawFilenameHeader);
|
||||
} catch {
|
||||
return rawFilenameHeader;
|
||||
}
|
||||
})();
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
|
||||
117
apps/web/src/pages/partner/activate.tsx
Normal file
117
apps/web/src/pages/partner/activate.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { Auth } from "~/components/AuthForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
export default function PartnerActivatePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const licenseKey = searchParams.get("license_key");
|
||||
const error = searchParams.get("error");
|
||||
|
||||
const partnerName = env("NEXT_PUBLIC_PARTNER_NAME");
|
||||
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (licenseKey) {
|
||||
localStorage.setItem("partnerLicenseKey", licenseKey);
|
||||
}
|
||||
}, [licenseKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && session?.user && licenseKey) {
|
||||
router.push(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`,
|
||||
);
|
||||
} else if (!isPending && session?.user && !licenseKey) {
|
||||
router.push("/boards");
|
||||
}
|
||||
}, [session, isPending, licenseKey, router]);
|
||||
|
||||
if (isPending || (session?.user && licenseKey)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Activate | kan.bn`} />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
<Link href="/">
|
||||
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-2 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{isMagicLinkSent ? t`Check your inbox` : t`Activate your account`}
|
||||
</p>
|
||||
<p className="mb-10 text-sm text-light-800 dark:text-dark-800">
|
||||
{isMagicLinkSent ? (
|
||||
<Trans>We sent a link to {magicLinkRecipient}</Trans>
|
||||
) : partnerName ? (
|
||||
<Trans>
|
||||
Sign in or create an account to activate your {partnerName}{" "}
|
||||
license
|
||||
</Trans>
|
||||
) : (
|
||||
t`Sign in or create an account to activate your license`
|
||||
)}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 w-full rounded-md bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400 sm:max-w-md">
|
||||
{t`Something went wrong during activation. Please try again.`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMagicLinkSent && (
|
||||
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<Auth
|
||||
setIsMagicLinkSent={(val, recipient) => {
|
||||
setIsMagicLinkSent(val);
|
||||
setMagicLinkRecipient(recipient);
|
||||
}}
|
||||
callbackURL={
|
||||
licenseKey
|
||||
? `/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`
|
||||
: "/boards"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Already have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link
|
||||
href={
|
||||
licenseKey
|
||||
? `/login?next=${encodeURIComponent(`/api/partner/link?license_key=${licenseKey}`)}`
|
||||
: "/login"
|
||||
}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</span>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
apps/web/src/pages/upgrade/select-plan.tsx
Normal file
31
apps/web/src/pages/upgrade/select-plan.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import SelectPlanView from "~/views/onboarding/select-plan";
|
||||
|
||||
export default function UpgradeSelectPlanPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session?.user) {
|
||||
router.push("/login");
|
||||
}
|
||||
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||
router.push("/boards");
|
||||
}
|
||||
}, [session, isPending, router]);
|
||||
|
||||
if (isPending || !session?.user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Upgrade | kan.bn" />
|
||||
<SelectPlanView />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ interface Workspace {
|
||||
plan: "free" | "team" | "pro" | "enterprise" | undefined;
|
||||
role: "admin" | "member" | "guest";
|
||||
weekStartDay: 0 | 1 | 6;
|
||||
cardPrefix: string;
|
||||
}
|
||||
|
||||
const initialWorkspace: Workspace = {
|
||||
@@ -30,6 +31,7 @@ const initialWorkspace: Workspace = {
|
||||
plan: "free" as const,
|
||||
role: "member",
|
||||
weekStartDay: 1,
|
||||
cardPrefix: "",
|
||||
};
|
||||
|
||||
const initialAvailableWorkspaces: Workspace[] = [];
|
||||
@@ -56,9 +58,12 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const pollAttemptsRef = React.useRef(0);
|
||||
const MAX_POLL_ATTEMPTS = 5;
|
||||
|
||||
const { data, isLoading } = api.workspace.all.useQuery(undefined, {
|
||||
refetchInterval: pendingWorkspaceId ? 2000 : false,
|
||||
});
|
||||
const { data, isLoading, isFetching } = api.workspace.all.useQuery(
|
||||
undefined,
|
||||
{
|
||||
refetchInterval: pendingWorkspaceId ? 2000 : false,
|
||||
},
|
||||
);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const switchWorkspace = (_workspace: Workspace) => {
|
||||
@@ -74,7 +79,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.length) {
|
||||
if (!isLoading) setHasLoaded(true);
|
||||
if (!isLoading && !isFetching) setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,6 +95,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
description: workspace.description,
|
||||
plan: workspace.plan,
|
||||
weekStartDay: workspace.weekStartDay,
|
||||
cardPrefix: workspace.cardPrefix,
|
||||
hasLoaded: true,
|
||||
})) as Workspace[];
|
||||
|
||||
@@ -97,53 +103,72 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
|
||||
if (storedWorkspaceId !== null) {
|
||||
const newData = data;
|
||||
const selectedWorkspace = newData.find(
|
||||
const selectedWorkspace = data.find(
|
||||
({ workspace }) => workspace.publicId === storedWorkspaceId,
|
||||
);
|
||||
|
||||
if (!selectedWorkspace?.workspace) {
|
||||
pollAttemptsRef.current += 1;
|
||||
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
||||
setPendingWorkspaceId(null);
|
||||
if (pendingWorkspaceId) {
|
||||
pollAttemptsRef.current += 1;
|
||||
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
||||
setPendingWorkspaceId(null);
|
||||
localStorage.removeItem("workspacePublicId");
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Clear stale workspacePublicId from localStorage
|
||||
localStorage.removeItem("workspacePublicId");
|
||||
}
|
||||
} else {
|
||||
pollAttemptsRef.current = 0;
|
||||
setPendingWorkspaceId(null);
|
||||
|
||||
setWorkspace({
|
||||
publicId: selectedWorkspace.workspace.publicId,
|
||||
name: selectedWorkspace.workspace.name,
|
||||
slug: selectedWorkspace.workspace.slug,
|
||||
plan: selectedWorkspace.workspace.plan,
|
||||
description: selectedWorkspace.workspace.description,
|
||||
role: selectedWorkspace.role as "admin" | "member" | "guest",
|
||||
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||
cardPrefix: selectedWorkspace.workspace.cardPrefix,
|
||||
});
|
||||
|
||||
if (workspacePublicId) {
|
||||
router.push(`/boards`);
|
||||
localStorage.setItem("workspacePublicId", workspacePublicId);
|
||||
}
|
||||
|
||||
setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
pollAttemptsRef.current = 0;
|
||||
setPendingWorkspaceId(null);
|
||||
|
||||
setWorkspace({
|
||||
publicId: selectedWorkspace.workspace.publicId,
|
||||
name: selectedWorkspace.workspace.name,
|
||||
slug: selectedWorkspace.workspace.slug,
|
||||
plan: selectedWorkspace.workspace.plan,
|
||||
description: selectedWorkspace.workspace.description,
|
||||
role: selectedWorkspace.role,
|
||||
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||
});
|
||||
|
||||
if (workspacePublicId) {
|
||||
router.push(`/boards`);
|
||||
localStorage.setItem("workspacePublicId", workspacePublicId);
|
||||
}
|
||||
} else {
|
||||
const primaryWorkspace = data[0]?.workspace;
|
||||
const primaryWorkspaceRole = data[0]?.role;
|
||||
|
||||
if (!primaryWorkspace || !primaryWorkspaceRole) return;
|
||||
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
|
||||
setWorkspace({
|
||||
publicId: primaryWorkspace.publicId,
|
||||
name: primaryWorkspace.name,
|
||||
slug: primaryWorkspace.slug,
|
||||
plan: primaryWorkspace.plan,
|
||||
description: primaryWorkspace.description,
|
||||
role: primaryWorkspaceRole,
|
||||
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
||||
});
|
||||
}
|
||||
}, [data, isLoading, workspacePublicId, router]);
|
||||
|
||||
const primaryWorkspace = data[0]?.workspace;
|
||||
const primaryWorkspaceRole = data[0]?.role;
|
||||
|
||||
if (!primaryWorkspace || !primaryWorkspaceRole) return;
|
||||
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
|
||||
setWorkspace({
|
||||
publicId: primaryWorkspace.publicId,
|
||||
name: primaryWorkspace.name,
|
||||
slug: primaryWorkspace.slug,
|
||||
plan: primaryWorkspace.plan,
|
||||
description: primaryWorkspace.description,
|
||||
role: primaryWorkspaceRole as "admin" | "member" | "guest",
|
||||
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
||||
cardPrefix: primaryWorkspace.cardPrefix,
|
||||
});
|
||||
setHasLoaded(true);
|
||||
}, [
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
workspacePublicId,
|
||||
pendingWorkspaceId,
|
||||
router,
|
||||
]);
|
||||
|
||||
return (
|
||||
<WorkspaceContext.Provider
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function LoginPage() {
|
||||
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
if (data?.user.id) router.push("/boards");
|
||||
if (data?.user.id) router.push(redirect ?? "/boards");
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function SignUpPage() {
|
||||
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
if (data?.user.id) router.push("/boards");
|
||||
if (data?.user.id) router.push(redirect ?? "/boards");
|
||||
|
||||
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
||||
setIsMagicLinkSent(value);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
const Card = ({
|
||||
title,
|
||||
ticketNumber,
|
||||
labels,
|
||||
members,
|
||||
checklists,
|
||||
@@ -25,6 +26,7 @@ const Card = ({
|
||||
dueDate,
|
||||
}: {
|
||||
title: string;
|
||||
ticketNumber?: string | null;
|
||||
labels: { name: string; colourCode: string | null }[];
|
||||
members: {
|
||||
publicId: string;
|
||||
@@ -67,6 +69,11 @@ const Card = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
{ticketNumber && (
|
||||
<span className="mb-1 text-xs text-light-700 dark:text-dark-800">
|
||||
{ticketNumber}
|
||||
</span>
|
||||
)}
|
||||
<span className="break-words">{title}</span>
|
||||
{labels.length ||
|
||||
members.length ||
|
||||
|
||||
@@ -146,6 +146,10 @@ export function NewCardForm({
|
||||
listId: 2,
|
||||
description: "",
|
||||
dueDate: args.dueDate ?? null,
|
||||
cardNumber: null,
|
||||
comments: [],
|
||||
checklists: [],
|
||||
attachments: [],
|
||||
labels: oldBoard.labels.filter((label) =>
|
||||
args.labelPublicIds.includes(label.publicId),
|
||||
),
|
||||
@@ -158,6 +162,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 +522,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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { DropResult } from "react-beautiful-dnd";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useRouter } from "next/router";
|
||||
import { env } from "next-runtime-env";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -757,6 +757,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
>
|
||||
<Card
|
||||
title={card.title}
|
||||
ticketNumber={
|
||||
card.cardNumber != null
|
||||
? `${boardData.workspace.cardPrefix}-${card.cardNumber}`
|
||||
: null
|
||||
}
|
||||
labels={card.labels}
|
||||
members={card.members}
|
||||
checklists={card.checklists ?? []}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useRef, useState } from "react";
|
||||
import { HiOutlinePaperClip } from "react-icons/hi";
|
||||
import { HiCheckBadge } from "react-icons/hi2";
|
||||
@@ -7,7 +8,6 @@ import { twMerge } from "tailwind-merge";
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { env } from "next-runtime-env";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
@@ -30,7 +30,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
"x-original-filename": file.name,
|
||||
"x-original-filename": encodeURIComponent(file.name),
|
||||
},
|
||||
body: file,
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiHashtag,
|
||||
HiLink,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineDocumentDuplicate,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
@@ -12,24 +14,52 @@ import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function CardDropdown({
|
||||
cardPublicId,
|
||||
isTemplate,
|
||||
boardPublicId,
|
||||
cardCreatedBy,
|
||||
ticketNumber,
|
||||
listPublicId,
|
||||
cardIndex,
|
||||
}: {
|
||||
cardPublicId: string;
|
||||
isTemplate?: boolean;
|
||||
boardPublicId?: string;
|
||||
cardCreatedBy?: string | null;
|
||||
ticketNumber?: string | null;
|
||||
listPublicId?: string;
|
||||
cardIndex?: number;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditCard, canDeleteCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const utils = api.useUtils();
|
||||
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
||||
|
||||
const duplicateCard = api.card.duplicate.useMutation({
|
||||
onSuccess: () => {
|
||||
showPopup({
|
||||
header: t`Card duplicated`,
|
||||
icon: "success",
|
||||
message: t`Card duplicated successfully.`,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to duplicate card`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopyCardLink = async () => {
|
||||
const path =
|
||||
isTemplate && boardPublicId
|
||||
@@ -53,12 +83,40 @@ export default function CardDropdown({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyTicketId = async () => {
|
||||
if (!ticketNumber) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(ticketNumber);
|
||||
showPopup({
|
||||
header: t`ID copied`,
|
||||
icon: "success",
|
||||
message: t`Ticket ID copied to clipboard`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showPopup({
|
||||
header: t`Unable to copy ID`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: t`Copy card link`,
|
||||
action: handleCopyCardLink,
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
...(ticketNumber
|
||||
? [
|
||||
{
|
||||
label: t`Copy ticket ID`,
|
||||
action: handleCopyTicketId,
|
||||
icon: <HiHashtag className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canEditCard
|
||||
? [
|
||||
{
|
||||
@@ -68,6 +126,24 @@ export default function CardDropdown({
|
||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Duplicate card`,
|
||||
action: () => {
|
||||
if (!listPublicId || cardIndex === undefined) return;
|
||||
duplicateCard.mutate({
|
||||
cardPublicId,
|
||||
listPublicId,
|
||||
index: cardIndex + 1,
|
||||
copyLabels: true,
|
||||
copyMembers: true,
|
||||
copyChecklists: true,
|
||||
});
|
||||
},
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
disabled: duplicateCard.isPending || !listPublicId,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canDeleteCard || isCreator
|
||||
|
||||
@@ -185,7 +185,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
|
||||
const { data: card, isLoading, error } = api.card.byId.useQuery(
|
||||
const {
|
||||
data: card,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardId ?? "" },
|
||||
{ enabled: !!cardId && cardId.length >= 12 },
|
||||
);
|
||||
@@ -340,6 +344,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
>
|
||||
{board?.name}
|
||||
</Link>
|
||||
{card.cardNumber != null &&
|
||||
card.list.board.workspace.cardPrefix && (
|
||||
<>
|
||||
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
|
||||
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
|
||||
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown
|
||||
@@ -347,6 +360,14 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isTemplate={isTemplate}
|
||||
boardPublicId={boardId}
|
||||
cardCreatedBy={card?.createdBy}
|
||||
ticketNumber={
|
||||
card.cardNumber != null &&
|
||||
card.list.board.workspace.cardPrefix
|
||||
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
|
||||
: null
|
||||
}
|
||||
listPublicId={card?.list.publicId}
|
||||
cardIndex={card?.index}
|
||||
/>
|
||||
<Link
|
||||
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
|
||||
|
||||
@@ -36,6 +36,17 @@ export default function InvitePage() {
|
||||
return router.push(`/boards`);
|
||||
}
|
||||
|
||||
if (
|
||||
error.data?.code === "FORBIDDEN" &&
|
||||
error.message === "SEAT_LIMIT_REACHED"
|
||||
) {
|
||||
setError(
|
||||
t`This workspace has reached its member limit. The workspace owner will need to upgrade their plan.`,
|
||||
);
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(
|
||||
error.message ||
|
||||
t`Failed to accept invitation. Please try again later, or contact customer support.`,
|
||||
|
||||
@@ -13,7 +13,6 @@ import { z } from "zod";
|
||||
|
||||
import type { InviteMemberInput } from "@kan/api/types";
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import { getSubscriptionByPlan } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -25,15 +24,15 @@ import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function InviteMemberForm({
|
||||
numberOfMembers,
|
||||
subscriptions,
|
||||
unlimitedSeats,
|
||||
userId,
|
||||
memberCount,
|
||||
seatLimit,
|
||||
}: {
|
||||
numberOfMembers: number;
|
||||
subscriptions: Subscription[] | undefined;
|
||||
unlimitedSeats: boolean;
|
||||
userId: string | undefined;
|
||||
memberCount: number;
|
||||
seatLimit: number | null;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
|
||||
@@ -98,6 +97,15 @@ export function InviteMemberForm({
|
||||
message: t`User is already a member of this workspace`,
|
||||
icon: "error",
|
||||
});
|
||||
} else if (
|
||||
error.data?.code === "FORBIDDEN" &&
|
||||
error.message === "SEAT_LIMIT_REACHED"
|
||||
) {
|
||||
showPopup({
|
||||
header: t`Seat limit reached`,
|
||||
message: t`You've reached your ${seatLimit ?? 0}-seat limit. Please upgrade your plan to add more members.`,
|
||||
icon: "error",
|
||||
});
|
||||
} else {
|
||||
showPopup({
|
||||
header: t`Error inviting member`,
|
||||
@@ -153,6 +161,9 @@ export function InviteMemberForm({
|
||||
|
||||
const hasTeamSubscription = !!teamSubscription;
|
||||
const hasProSubscription = !!proSubscription;
|
||||
const isPartnerTier = !!(
|
||||
teamSubscription?.partnerTier ?? proSubscription?.partnerTier
|
||||
);
|
||||
|
||||
let isYearly = false;
|
||||
let price = t`$10/month`;
|
||||
@@ -175,13 +186,13 @@ export function InviteMemberForm({
|
||||
inviteMember.mutate(member);
|
||||
};
|
||||
|
||||
const isFreePlan =
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription;
|
||||
|
||||
const handleInviteLinkToggle = async () => {
|
||||
if (
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
)
|
||||
return handleUpgrade();
|
||||
if (isFreePlan) return;
|
||||
|
||||
setIsLoadingInviteLink(true);
|
||||
|
||||
@@ -219,31 +230,6 @@ export function InviteMemberForm({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
const { data, error } = await authClient.subscription.upgrade({
|
||||
plan: "team",
|
||||
referenceId: workspace.publicId,
|
||||
metadata: { userId },
|
||||
seats: numberOfMembers,
|
||||
successUrl: "/members",
|
||||
cancelUrl: "/members",
|
||||
returnUrl: "/members",
|
||||
disableRedirect: true,
|
||||
});
|
||||
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
showPopup({
|
||||
header: t`Error upgrading subscription`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const emailElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#email");
|
||||
@@ -270,11 +256,7 @@ export function InviteMemberForm({
|
||||
<Input
|
||||
id="email"
|
||||
placeholder={t`Email`}
|
||||
disabled={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
}
|
||||
disabled={isFreePlan}
|
||||
{...register("email", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -285,78 +267,92 @@ export function InviteMemberForm({
|
||||
errorMessage={errors.email?.message}
|
||||
/>
|
||||
)}
|
||||
{(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
|
||||
<div className="my-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inviteLink}
|
||||
className="pr-10 text-sm text-light-900 dark:text-dark-900"
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{copied ? (
|
||||
<HiMiniCheck className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<HiOutlineDocumentDuplicate className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
{!isFreePlan &&
|
||||
(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
|
||||
<div className="my-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inviteLink}
|
||||
className="pr-10 text-sm text-light-900 dark:text-dark-900"
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{copied ? (
|
||||
<HiMiniCheck className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<HiOutlineDocumentDuplicate className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-start gap-1">
|
||||
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
|
||||
<p className="text-xs text-gray-500 dark:text-dark-900">
|
||||
{t`Anyone with this link can join your workspace`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-start gap-1">
|
||||
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
|
||||
<p className="text-xs text-gray-500 dark:text-dark-900">
|
||||
{t`Anyone with this link can join your workspace`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
{hasTeamSubscription || hasProSubscription ? (
|
||||
<div>
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
(isPartnerTier && seatLimit !== null ? (
|
||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
|
||||
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{unlimitedSeats
|
||||
? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
|
||||
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="font-medium text-light-950 dark:text-dark-950">
|
||||
{t`Free Plan`}
|
||||
<span className="text-light-900 dark:text-dark-900">
|
||||
{memberCount} / {seatLimit} {t`seats`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{t`Inviting members requires a Team Plan. You'll be redirected to upgrade your workspace.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : !unlimitedSeats ? (
|
||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
{hasTeamSubscription || hasProSubscription ? (
|
||||
<div>
|
||||
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="font-medium text-light-950 dark:text-dark-950">
|
||||
{t`Free Plan`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{t`Inviting members requires a Team or Pro plan. You'll be redirected to upgrade your workspace.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null)}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={
|
||||
isShareInviteLinkEnabled
|
||||
? t`Deactivate invite link`
|
||||
: t`Create invite link`
|
||||
}
|
||||
isChecked={isShareInviteLinkEnabled}
|
||||
onChange={handleInviteLinkToggle}
|
||||
/>
|
||||
<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">
|
||||
{!isFreePlan && (
|
||||
<Toggle
|
||||
label={
|
||||
isShareInviteLinkEnabled
|
||||
? t`Deactivate invite link`
|
||||
: t`Create invite link`
|
||||
}
|
||||
isChecked={isShareInviteLinkEnabled}
|
||||
onChange={handleInviteLinkToggle}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription ? (
|
||||
<Button type="button" onClick={handleUpgrade}>
|
||||
{t`Start 14 day free trial`}
|
||||
{isFreePlan ? (
|
||||
<Button
|
||||
type="button"
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
|
||||
>
|
||||
{t`Choose plan`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -11,7 +11,11 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
|
||||
import {
|
||||
getSeatLimit,
|
||||
getSubscriptionByPlan,
|
||||
hasUnlimitedSeats,
|
||||
} from "@kan/shared/utils";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
@@ -27,8 +31,8 @@ import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
|
||||
import { InviteMemberForm } from "./components/InviteMemberForm";
|
||||
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
|
||||
import { InviteMemberForm } from "./components/InviteMemberForm";
|
||||
|
||||
export default function MembersPage() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
@@ -76,6 +80,23 @@ export default function MembersPage() {
|
||||
|
||||
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
||||
|
||||
const isProPlan =
|
||||
!!proSubscription ||
|
||||
workspace.plan === "pro" ||
|
||||
workspace.plan === "enterprise";
|
||||
const isTeamPlan = !!teamSubscription || workspace.plan === "team";
|
||||
const isPaidPlan = isProPlan || isTeamPlan;
|
||||
|
||||
const activeMembers = data?.members.length ?? 0;
|
||||
const seatLimit = getSeatLimit(subscriptions);
|
||||
const memberCount =
|
||||
data?.members.filter((m) => m.status === "active" || m.status === "invited")
|
||||
.length ?? 0;
|
||||
const totalSeats =
|
||||
teamSubscription?.seats ??
|
||||
proSubscription?.seats ??
|
||||
(isPaidPlan ? null : 1);
|
||||
|
||||
const TableRow = ({
|
||||
memberPublicId,
|
||||
memberId,
|
||||
@@ -144,8 +165,8 @@ export default function MembersPage() {
|
||||
{memberName}
|
||||
</p>
|
||||
</div>
|
||||
{((workspace.role === "admin" ||
|
||||
data?.showEmailsToMembers === true) ||
|
||||
{(workspace.role === "admin" ||
|
||||
data?.showEmailsToMembers === true ||
|
||||
showSkeleton) && (
|
||||
<p
|
||||
className={twMerge(
|
||||
@@ -180,8 +201,7 @@ export default function MembersPage() {
|
||||
<div className="relative inline-flex items-center">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]">
|
||||
{memberRole &&
|
||||
memberRole.charAt(0).toUpperCase() +
|
||||
memberRole.slice(1)}
|
||||
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
|
||||
{canEditMember && session?.user.id !== memberId && (
|
||||
<HiChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
@@ -264,38 +284,42 @@ export default function MembersPage() {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && !!data && (
|
||||
<>
|
||||
{!proSubscription && !teamSubscription && (
|
||||
{!isPaidPlan && (
|
||||
<Link
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
|
||||
className="hidden items-center rounded-full border border-emerald-300 bg-emerald-50 px-3 py-1 text-center text-xs text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400 lg:flex"
|
||||
>
|
||||
<HiBolt />
|
||||
<span className="ml-1 font-medium">
|
||||
{t`Launch offer: Get unlimited members with Pro`}
|
||||
</span>
|
||||
<span className="ml-1 font-medium">{t`Upgrade`}</span>
|
||||
</Link>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center rounded-full border px-3 py-1 text-center text-xs",
|
||||
teamSubscription || proSubscription
|
||||
isPaidPlan
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
: "border-light-300 bg-light-50 text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{proSubscription
|
||||
{isProPlan
|
||||
? t`Pro Plan`
|
||||
: teamSubscription
|
||||
: isTeamPlan
|
||||
? t`Team Plan`
|
||||
: t`Free Plan`}
|
||||
{proSubscription && unlimitedSeats && (
|
||||
<span className="ml-1 text-xs">∞</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isPaidPlan && (unlimitedSeats || totalSeats !== null) && (
|
||||
<div className="flex items-center rounded-full border border-light-300 bg-light-50 px-3 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
||||
<span className="font-medium">
|
||||
{unlimitedSeats
|
||||
? t`Unlimited seats`
|
||||
: `${activeMembers}/${totalSeats} ${t`seats`}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
@@ -384,10 +408,10 @@ export default function MembersPage() {
|
||||
isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
|
||||
>
|
||||
<InviteMemberForm
|
||||
userId={session?.user.id}
|
||||
numberOfMembers={data?.members.length ?? 1}
|
||||
subscriptions={subscriptions}
|
||||
unlimitedSeats={unlimitedSeats}
|
||||
memberCount={memberCount}
|
||||
seatLimit={seatLimit}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Radio, RadioGroup } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { HiUser } from "react-icons/hi2";
|
||||
@@ -48,6 +48,7 @@ export default function SelectPlanView() {
|
||||
(searchParams.get("billing") as Billing | null) ?? "annual",
|
||||
);
|
||||
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
|
||||
const workspacePublicId = searchParams.get("workspacePublicId");
|
||||
const { data: workspaces } = api.workspace.all.useQuery();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: user } = api.user.getUser.useQuery(undefined, {
|
||||
@@ -96,18 +97,44 @@ export default function SelectPlanView() {
|
||||
},
|
||||
];
|
||||
|
||||
const buildSelectPlanUrl = (plan: PlanId, b: Billing) => {
|
||||
const base = `/onboarding/select-plan?plan=${plan}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||
return workspacePublicId
|
||||
? `${base}&workspacePublicId=${workspacePublicId}`
|
||||
: base;
|
||||
};
|
||||
|
||||
const handleSelectPlan = (plan: PlanId) => {
|
||||
setSelected(plan);
|
||||
router.replace(`/onboarding/select-plan?plan=${plan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
|
||||
router.replace(buildSelectPlanUrl(plan, billing));
|
||||
};
|
||||
|
||||
const handleSetBilling = (b: Billing) => {
|
||||
setBilling(b);
|
||||
router.replace(`/onboarding/select-plan?plan=${selected}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`);
|
||||
router.replace(buildSelectPlanUrl(selected, b));
|
||||
};
|
||||
|
||||
const handleContinue = () =>
|
||||
router.push(`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
|
||||
const handleContinue = async () => {
|
||||
if (workspacePublicId && selected !== "solo") {
|
||||
const response = await fetch("/api/stripe/create_checkout_session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
plan: selected,
|
||||
billing,
|
||||
workspacePublicId,
|
||||
successUrl: returnUrl,
|
||||
cancelUrl: returnUrl,
|
||||
}),
|
||||
});
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
if (url) window.location.href = url;
|
||||
return;
|
||||
}
|
||||
router.push(
|
||||
`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancel = () => router.push(returnUrl);
|
||||
|
||||
@@ -209,7 +236,11 @@ export default function SelectPlanView() {
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleContinue}>{t`Continue`}</Button>
|
||||
<Button onClick={() => void handleContinue()}>
|
||||
{workspacePublicId && selected !== "solo"
|
||||
? t`Upgrade`
|
||||
: t`Continue`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,8 +39,19 @@ export default function WorkspaceNameView() {
|
||||
const plan = searchParams.get("plan") ?? "solo";
|
||||
const billing = searchParams.get("billing") ?? "annual";
|
||||
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
|
||||
const licenseKeyParam = searchParams.get("license_key");
|
||||
const isLicenseFlow =
|
||||
!!licenseKeyParam || searchParams.get("partner") === "1";
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
useEffect(() => {
|
||||
if (licenseKeyParam) {
|
||||
localStorage.setItem("partnerLicenseKey", licenseKeyParam);
|
||||
}
|
||||
}, [licenseKeyParam]);
|
||||
|
||||
const [isProToggle, setIsProToggle] = useState(plan === "pro");
|
||||
const effectivePlan = isProToggle ? "pro" : plan;
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
@@ -93,7 +104,16 @@ export default function WorkspaceNameView() {
|
||||
if (!workspace.publicId) return;
|
||||
localStorage.setItem("workspacePublicId", workspace.publicId);
|
||||
void utils.workspace.all.invalidate();
|
||||
router.push("/boards");
|
||||
void utils.workspace.hasAvailablePartnerSlot.invalidate();
|
||||
const storedLicenseKey = localStorage.getItem("partnerLicenseKey");
|
||||
if (storedLicenseKey) {
|
||||
localStorage.removeItem("partnerLicenseKey");
|
||||
router.push(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(storedLicenseKey)}`,
|
||||
);
|
||||
} else {
|
||||
router.push("/boards");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
@@ -109,7 +129,7 @@ export default function WorkspaceNameView() {
|
||||
const handleContinue = async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
if (plan === "solo") {
|
||||
if (effectivePlan === "solo" || isLicenseFlow) {
|
||||
createWorkspace.mutate({
|
||||
name: name.trim(),
|
||||
...(description.trim() && { description: description.trim() }),
|
||||
@@ -128,11 +148,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();
|
||||
@@ -192,50 +212,52 @@ export default function WorkspaceNameView() {
|
||||
maxLength={64}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder={t`your-workspace`}
|
||||
value={isProToggle ? slug : t`your-workspace`}
|
||||
onChange={(e) => {
|
||||
setSlugManuallyEdited(true);
|
||||
setSlug(
|
||||
e.target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 60),
|
||||
);
|
||||
}}
|
||||
disabled={!isProToggle}
|
||||
prefix="kan.bn/"
|
||||
className={
|
||||
!isProToggle ? "cursor-not-allowed opacity-50" : ""
|
||||
}
|
||||
errorMessage={slugError}
|
||||
iconRight={
|
||||
!isProToggle ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
|
||||
}
|
||||
placement="top"
|
||||
delay={0}
|
||||
>
|
||||
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
|
||||
</Tooltip>
|
||||
) : isProToggle && slug.length >= 3 ? (
|
||||
isTyping || slugAvailability.isPending ? (
|
||||
<LoadingSpinner />
|
||||
) : isSlugAvailable ? (
|
||||
<HiCheck className="h-4 w-4 text-white" />
|
||||
{!isLicenseFlow && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder={t`your-workspace`}
|
||||
value={isProToggle ? slug : t`your-workspace`}
|
||||
onChange={(e) => {
|
||||
setSlugManuallyEdited(true);
|
||||
setSlug(
|
||||
e.target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 60),
|
||||
);
|
||||
}}
|
||||
disabled={!isProToggle}
|
||||
prefix="kan.bn/"
|
||||
className={
|
||||
!isProToggle ? "cursor-not-allowed opacity-50" : ""
|
||||
}
|
||||
errorMessage={slugError}
|
||||
iconRight={
|
||||
!isProToggle ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
|
||||
}
|
||||
placement="top"
|
||||
delay={0}
|
||||
>
|
||||
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
|
||||
</Tooltip>
|
||||
) : isProToggle && slug.length >= 3 ? (
|
||||
isTyping || slugAvailability.isPending ? (
|
||||
<LoadingSpinner />
|
||||
) : isSlugAvailable ? (
|
||||
<HiCheck className="h-4 w-4 text-white" />
|
||||
) : null
|
||||
) : null
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan !== "pro" && (
|
||||
{!isLicenseFlow && plan !== "pro" && (
|
||||
<div className="pb-2">
|
||||
<Toggle
|
||||
isChecked={isProToggle}
|
||||
@@ -264,16 +286,18 @@ export default function WorkspaceNameView() {
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
router.replace(
|
||||
`/onboarding/select-plan?plan=${plan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Back`}
|
||||
</Button>
|
||||
{!isLicenseFlow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
router.replace(
|
||||
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Back`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => void handleContinue()}
|
||||
disabled={
|
||||
|
||||
@@ -95,8 +95,28 @@ export function CardModal({
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="h-full p-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1">
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data?.cardNumber != null &&
|
||||
data.list.board.workspace.cardPrefix && (
|
||||
<span className="mb-1 block text-xs font-medium text-light-700 dark:text-dark-800">
|
||||
{data.list.board.workspace.cardPrefix}-
|
||||
{data.cardNumber}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{data?.title}
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCardLink}
|
||||
@@ -134,17 +154,6 @@ export function CardModal({
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{data?.title}
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{labels.length > 0 && (
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -85,17 +85,19 @@ export default function AccountSettings() {
|
||||
{isCredentialsEnabled && (
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Change Password`}
|
||||
{data?.hasPassword ? t`Change Password` : t`Set Password`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`You are about to change your password.`}
|
||||
{data?.hasPassword
|
||||
? t`You are about to change your password.`
|
||||
: t`Set a password to enable password-based login.`}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openModal("CHANGE_PASSWORD")}
|
||||
>
|
||||
{t`Change Password`}
|
||||
{data?.hasPassword ? t`Change Password` : t`Set Password`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,7 +115,7 @@ export default function AccountSettings() {
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
|
||||
>
|
||||
<ChangePasswordFormConfirmation />
|
||||
<ChangePasswordFormConfirmation hasPassword={data?.hasPassword ?? false} />
|
||||
</Modal>
|
||||
|
||||
{/* Global modals */}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -7,24 +9,49 @@ import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function BillingSettings() {
|
||||
const { modalContentType, isOpen } = useModal();
|
||||
const router = useRouter();
|
||||
const isCloud = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||
);
|
||||
|
||||
const subscription = workspaceData?.subscriptions.find((s) =>
|
||||
["active", "trialing", "past_due"].includes(s.status),
|
||||
);
|
||||
|
||||
const planLabel = (() => {
|
||||
if (!subscription) return t`Free (1 member)`;
|
||||
if (subscription.unlimitedSeats) {
|
||||
const name =
|
||||
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
|
||||
return `${name} (${t`unlimited members`})`;
|
||||
}
|
||||
if (subscription.seats != null) {
|
||||
const name =
|
||||
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
|
||||
return `${name} (${subscription.seats} ${subscription.seats === 1 ? t`member` : t`members`})`;
|
||||
}
|
||||
return (
|
||||
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1)
|
||||
);
|
||||
})();
|
||||
|
||||
const handleOpenBillingPortal = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create_billing_session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
if (url) window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error("Error creating billing session:", error);
|
||||
}
|
||||
@@ -34,6 +61,28 @@ export default function BillingSettings() {
|
||||
<>
|
||||
<PageHead title={t`Settings | Billing`} />
|
||||
|
||||
<div className="border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Plan`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{planLabel}
|
||||
</p>
|
||||
{!subscription && isCloud && (
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Choose plan`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Billing`}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiBolt } from "react-icons/hi2";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { hasActiveSubscription } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
@@ -17,43 +12,21 @@ import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
|
||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
|
||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||
|
||||
export default function WorkspaceSettings() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canEditWorkspace } = usePermissions();
|
||||
const router = useRouter();
|
||||
const { data } = api.user.getUser.useQuery();
|
||||
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||
);
|
||||
|
||||
const subscriptions = workspaceData?.subscriptions as
|
||||
| Subscription[]
|
||||
| undefined;
|
||||
|
||||
// Open upgrade modal if upgrade=pro is in URL params
|
||||
useEffect(() => {
|
||||
if (
|
||||
router.query.upgrade === "pro" &&
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") &&
|
||||
!hasOpenedUpgradeModal
|
||||
) {
|
||||
openModal("UPGRADE_TO_PRO");
|
||||
setHasOpenedUpgradeModal(true);
|
||||
}
|
||||
}, [router.query.upgrade, subscriptions, openModal, hasOpenedUpgradeModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Settings | Workspace`} />
|
||||
@@ -107,19 +80,6 @@ export default function WorkspaceSettings() {
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") &&
|
||||
!hasActiveSubscription(subscriptions, "team") && (
|
||||
<div className="my-8">
|
||||
<Button
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
iconRight={<HiBolt />}
|
||||
>
|
||||
{t`Upgrade to Pro`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Delete workspace`}
|
||||
@@ -146,16 +106,6 @@ export default function WorkspaceSettings() {
|
||||
>
|
||||
<DeleteWorkspaceConfirmation />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"}
|
||||
>
|
||||
<UpgradeToProConfirmation
|
||||
userId={data?.id ?? ""}
|
||||
workspacePublicId={workspace.publicId}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Global modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function Avatar({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": blob.type,
|
||||
"x-original-filename": fileName,
|
||||
"x-original-filename": encodeURIComponent(fileName),
|
||||
},
|
||||
body: blob,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useRouter } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -14,27 +13,43 @@ import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, t`Current password is required`),
|
||||
const buildSchema = (hasPassword: boolean) => {
|
||||
const base = z.object({
|
||||
currentPassword: hasPassword
|
||||
? z.string().min(1, t`Current password is required`)
|
||||
: z.string().optional(),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, t`Password must be at least 8 characters`)
|
||||
.min(1, t`New password is required`),
|
||||
confirmPassword: z.string().min(1, t`Please confirm your new password`),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t`Passwords do not match`,
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
.refine((data) => data.currentPassword !== data.newPassword, {
|
||||
message: t`New password must be different from current password`,
|
||||
path: ["newPassword"],
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof FormSchema>;
|
||||
return base
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t`Passwords do not match`,
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
.refine(
|
||||
(data) => !hasPassword || data.currentPassword !== data.newPassword,
|
||||
{
|
||||
message: t`New password must be different from current password`,
|
||||
path: ["newPassword"],
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export function ChangePasswordFormConfirmation() {
|
||||
interface FormValues {
|
||||
currentPassword?: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
@@ -47,15 +62,24 @@ export function ChangePasswordFormConfirmation() {
|
||||
reset,
|
||||
setError,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
resolver: zodResolver(buildSchema(hasPassword)),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const setPasswordMutation = api.user.setPassword.useMutation();
|
||||
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: async (data: FormValues) => {
|
||||
if (!hasPassword) {
|
||||
await setPasswordMutation.mutateAsync({
|
||||
newPassword: data.newPassword,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await authClient.changePassword({
|
||||
newPassword: data.newPassword,
|
||||
currentPassword: data.currentPassword,
|
||||
currentPassword: data.currentPassword ?? "",
|
||||
revokeOtherSessions: true,
|
||||
});
|
||||
|
||||
@@ -66,12 +90,18 @@ export function ChangePasswordFormConfirmation() {
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: t`Password Changed`,
|
||||
message: t`Your password has been changed.`,
|
||||
header: hasPassword ? t`Password Changed` : t`Password Set`,
|
||||
message: hasPassword
|
||||
? t`Your password has been changed.`
|
||||
: t`Your password has been set.`,
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
utils.invalidate();
|
||||
if (!hasPassword) {
|
||||
sessionStorage.removeItem("set_password_prompted");
|
||||
}
|
||||
|
||||
utils.user.getUser.invalidate();
|
||||
reset();
|
||||
router.push("/");
|
||||
},
|
||||
@@ -86,7 +116,9 @@ export function ChangePasswordFormConfirmation() {
|
||||
} else {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: t`Error Changing Password`,
|
||||
header: hasPassword
|
||||
? t`Error Changing Password`
|
||||
: t`Error Setting Password`,
|
||||
message: t`An unexpected error occurred. Please try again later.`,
|
||||
icon: "error",
|
||||
});
|
||||
@@ -106,27 +138,33 @@ export function ChangePasswordFormConfirmation() {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium dark:text-white">{t`Change Password`}</h2>
|
||||
<h2 className="pb-4 text-base font-medium dark:text-white">
|
||||
{hasPassword ? t`Change Password` : t`Set Password`}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-light-900">
|
||||
{t`Enter your current password and choose a new secure password.`}
|
||||
{hasPassword
|
||||
? t`Enter your current password and choose a new secure password.`
|
||||
: t`You signed in without a password. Set a password to enable password-based login.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
{...register("currentPassword")}
|
||||
placeholder={t`Enter your current password`}
|
||||
/>
|
||||
{errors.currentPassword && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.currentPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{hasPassword && (
|
||||
<div>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
{...register("currentPassword")}
|
||||
placeholder={t`Enter your current password`}
|
||||
/>
|
||||
{errors.currentPassword && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.currentPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Input
|
||||
@@ -175,7 +213,7 @@ export function ChangePasswordFormConfirmation() {
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{t`Change Password`}
|
||||
{hasPassword ? t`Change Password` : t`Set Password`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function UpdateWeekStartDayForm({
|
||||
setValue(newValue);
|
||||
updateWorkspace.mutate({
|
||||
workspacePublicId,
|
||||
weekStartDay: newValue,
|
||||
weekStartDay: newValue as 0 | 1 | 6,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -24,6 +24,8 @@ export function UpgradeToProConfirmation({
|
||||
body: JSON.stringify({
|
||||
...(entityId && { slug: entityId }),
|
||||
workspacePublicId: workspacePublicId,
|
||||
plan: "pro",
|
||||
billing: "monthly",
|
||||
cancelUrl: "/settings",
|
||||
successUrl: "/settings",
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -99,6 +99,19 @@ services:
|
||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
|
||||
|
||||
# Partner config (optional)
|
||||
- NEXT_PUBLIC_PARTNER_NAME=${NEXT_PUBLIC_PARTNER_NAME}
|
||||
- PARTNER_CLIENT_ID=${PARTNER_CLIENT_ID}
|
||||
- PARTNER_CLIENT_SECRET=${PARTNER_CLIENT_SECRET}
|
||||
- PARTNER_REDIRECT_URL=${PARTNER_REDIRECT_URL}
|
||||
- PARTNER_TOKEN_URL=${PARTNER_TOKEN_URL}
|
||||
- PARTNER_OAUTH_LICENSE_URL=${PARTNER_OAUTH_LICENSE_URL}
|
||||
- PARTNER_LICENSE_API_URL=${PARTNER_LICENSE_API_URL}
|
||||
- PARTNER_API_KEY=${PARTNER_API_KEY}
|
||||
- PARTNER_API_KEY_HEADER=${PARTNER_API_KEY_HEADER}
|
||||
- PARTNER_SIGNATURE_HEADER=${PARTNER_SIGNATURE_HEADER}
|
||||
- PARTNER_TIMESTAMP_HEADER=${PARTNER_TIMESTAMP_HEADER}
|
||||
depends_on:
|
||||
migrator:
|
||||
condition: service_completed_successfully
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -5,36 +5,40 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./root": {
|
||||
"types": "./dist/root.d.ts",
|
||||
"types": "./src/root.ts",
|
||||
"default": "./src/root.ts"
|
||||
},
|
||||
"./trpc": {
|
||||
"types": "./dist/trpc.d.ts",
|
||||
"types": "./src/trpc.ts",
|
||||
"default": "./src/trpc.ts"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"types": "./src/types/router.types.ts",
|
||||
"default": "./src/types/router.types.ts"
|
||||
},
|
||||
"./openapi": {
|
||||
"types": "./dist/openapi.d.ts",
|
||||
"types": "./src/openapi.ts",
|
||||
"default": "./src/openapi.ts"
|
||||
},
|
||||
"./utils/rateLimit": {
|
||||
"types": "./dist/utils/rateLimit.d.ts",
|
||||
"types": "./src/utils/rateLimit.ts",
|
||||
"default": "./src/utils/rateLimit.ts"
|
||||
},
|
||||
"./utils/apiLogging": {
|
||||
"types": "./dist/utils/apiLogging.d.ts",
|
||||
"types": "./src/utils/apiLogging.ts",
|
||||
"default": "./src/utils/apiLogging.ts"
|
||||
},
|
||||
"./utils/permissions": {
|
||||
"types": "./dist/utils/permissions.d.ts",
|
||||
"types": "./src/utils/permissions.ts",
|
||||
"default": "./src/utils/permissions.ts"
|
||||
},
|
||||
"./utils/workspace": {
|
||||
"types": "./src/utils/workspace.ts",
|
||||
"default": "./src/utils/workspace.ts"
|
||||
}
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -70,6 +78,7 @@ export const cardRouter = createTRPCRouter({
|
||||
description: input.description,
|
||||
createdBy: userId,
|
||||
listId: list.id,
|
||||
workspaceId: list.workspaceId,
|
||||
position: input.position,
|
||||
dueDate: input.dueDate ?? null,
|
||||
});
|
||||
@@ -178,10 +187,11 @@ export const cardRouter = createTRPCRouter({
|
||||
"card.created",
|
||||
{
|
||||
id: String(newCard.id),
|
||||
publicId: newCard.publicId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
dueDate: input.dueDate ?? null,
|
||||
listId: String(newCard.listId),
|
||||
listId: list.publicId,
|
||||
},
|
||||
{
|
||||
boardId: list.boardPublicId,
|
||||
@@ -215,7 +225,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 +298,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 +386,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 +445,7 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return deletedComment;
|
||||
return { publicId: input.commentPublicId };
|
||||
}),
|
||||
addOrRemoveLabel: protectedProcedure
|
||||
.meta({
|
||||
@@ -639,25 +649,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 +755,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 +855,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 +890,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,
|
||||
);
|
||||
@@ -953,7 +946,7 @@ export const cardRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
|
||||
if (input.index !== undefined) {
|
||||
if (input.index !== undefined || newListId !== undefined) {
|
||||
result = await cardRepo.reorder(ctx.db, {
|
||||
cardId: existingCard.id,
|
||||
newIndex: input.index,
|
||||
@@ -1055,8 +1048,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 +1068,19 @@ 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),
|
||||
publicId: result.publicId,
|
||||
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,
|
||||
@@ -1164,15 +1167,16 @@ export const cardRouter = createTRPCRouter({
|
||||
"card.deleted",
|
||||
{
|
||||
id: String(fullCard.id),
|
||||
publicId: fullCard.publicId,
|
||||
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,
|
||||
@@ -1272,6 +1276,7 @@ export const cardRouter = createTRPCRouter({
|
||||
description: sourceCard.description ?? "",
|
||||
createdBy: userId,
|
||||
listId: targetList.id,
|
||||
workspaceId: targetList.workspaceId,
|
||||
position: "end",
|
||||
dueDate: sourceCard.dueDate ?? null,
|
||||
});
|
||||
@@ -1342,7 +1347,7 @@ export const cardRouter = createTRPCRouter({
|
||||
checklistId: newChecklist.id,
|
||||
title: item.title,
|
||||
createdBy: userId,
|
||||
completed: item.completed ?? false,
|
||||
completed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +340,7 @@ export const importRouter = createTRPCRouter({
|
||||
description: card.description,
|
||||
createdBy: userId,
|
||||
listId: newListId,
|
||||
workspaceId: workspace.id,
|
||||
index,
|
||||
importId: newImportId,
|
||||
}));
|
||||
@@ -888,6 +889,7 @@ export const importRouter = createTRPCRouter({
|
||||
description: data.description,
|
||||
createdBy: userId,
|
||||
listId: data.listId,
|
||||
workspaceId: workspace.id,
|
||||
index: index,
|
||||
importId: newImportId,
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import {
|
||||
generateUID,
|
||||
getSeatLimit,
|
||||
getSubscriptionByPlan,
|
||||
hasUnlimitedSeats,
|
||||
} from "@kan/shared";
|
||||
import { updateSubscriptionSeats } from "@kan/stripe";
|
||||
|
||||
import { memberInviteResponseSchema } from "../schemas";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import {
|
||||
assertCanManageMember,
|
||||
@@ -40,7 +42,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;
|
||||
|
||||
@@ -113,6 +115,20 @@ export const memberRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const seatLimit = getSeatLimit(subscriptions);
|
||||
if (seatLimit !== null) {
|
||||
const memberCount = await memberRepo.getCountByWorkspaceId(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
);
|
||||
if (memberCount >= seatLimit) {
|
||||
throw new TRPCError({
|
||||
message: `SEAT_LIMIT_REACHED`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existingUser = await userRepo.getByEmail(ctx.db, input.email);
|
||||
@@ -643,6 +659,20 @@ export const memberRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const seatLimit = getSeatLimit(subscriptions);
|
||||
if (seatLimit !== null) {
|
||||
const memberCount = await memberRepo.getCountByWorkspaceId(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
);
|
||||
if (memberCount >= seatLimit) {
|
||||
throw new TRPCError({
|
||||
message: `SEAT_LIMIT_REACHED`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the workspace role to set roleId
|
||||
|
||||
@@ -2,9 +2,9 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import { generateAvatarUrl } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { generateAvatarUrl } from "@kan/shared/utils";
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
getUser: protectedProcedure
|
||||
@@ -27,6 +27,8 @@ export const userRouter = createTRPCRouter({
|
||||
name: z.string().nullable(),
|
||||
image: z.string().nullable(),
|
||||
stripeCustomerId: z.string().nullable(),
|
||||
hasPassword: z.boolean(),
|
||||
hasMagicLinkAccount: z.boolean(),
|
||||
apiKey: z
|
||||
.object({
|
||||
id: z.number(),
|
||||
@@ -61,6 +63,8 @@ export const userRouter = createTRPCRouter({
|
||||
return {
|
||||
...result,
|
||||
image: imageUrl,
|
||||
hasPassword: result.hasPassword,
|
||||
hasMagicLinkAccount: result.hasMagicLinkAccount,
|
||||
apiKey: apiKey ? { id: apiKey.id, prefix: apiKey.prefix } : null,
|
||||
};
|
||||
}),
|
||||
@@ -114,4 +118,60 @@ export const userRouter = createTRPCRouter({
|
||||
image: imageUrl,
|
||||
};
|
||||
}),
|
||||
setPassword: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "POST",
|
||||
path: "/users/me/password",
|
||||
summary: "Set password",
|
||||
description:
|
||||
"Sets a password for a user who signed up via magic link and has no password yet",
|
||||
tags: ["Users"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const existing = await userRepo.getById(ctx.db, userId);
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({
|
||||
message: `User not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
if (existing.hasPassword) {
|
||||
throw new TRPCError({
|
||||
message: `Password already set; use change password instead`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.auth.api.setPassword({ newPassword: input.newPassword });
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
message: "Failed to set password",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -334,6 +334,7 @@ export const webhookRouter = createTRPCRouter({
|
||||
|
||||
const testPayload = createCardWebhookPayload("card.created", {
|
||||
id: "test-card-id",
|
||||
publicId: "test-card-public-id",
|
||||
title: "Test Card",
|
||||
description: "This is a test webhook payload",
|
||||
dueDate: null,
|
||||
|
||||
@@ -2,10 +2,20 @@ import { TRPCError } from "@trpc/server";
|
||||
import { env } from "next-runtime-env";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { WorkspacePlan } from "@kan/db/schema";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
|
||||
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
|
||||
|
||||
import {
|
||||
workspaceCreateResponseSchema,
|
||||
workspaceDeleteResponseSchema,
|
||||
workspaceDetailSchema,
|
||||
workspaceListItemSchema,
|
||||
workspaceUpdateResponseSchema,
|
||||
workspaceWithBoardsSchema,
|
||||
} from "../schemas";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
|
||||
@@ -22,9 +32,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 +58,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 +138,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
return {
|
||||
...result,
|
||||
members: sanitizedMembers,
|
||||
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -162,9 +166,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 +213,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 +272,49 @@ export const workspaceRouter = createTRPCRouter({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
let unlinkedSlot: Awaited<
|
||||
ReturnType<typeof subscriptionRepo.getFirstUnlinkedSlotByLicenseKey>
|
||||
>;
|
||||
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||
const memberships = await workspaceRepo.getAllByUserId(ctx.db, userId);
|
||||
const otherWorkspaceIds = memberships
|
||||
.map((m) => m.workspace?.publicId)
|
||||
.filter((id): id is string => !!id && id !== workspacePublicId);
|
||||
|
||||
const partnerSub = otherWorkspaceIds.length
|
||||
? await subscriptionRepo.getFirstActivePartnerSubByWorkspaceIds(
|
||||
ctx.db,
|
||||
otherWorkspaceIds,
|
||||
)
|
||||
: undefined;
|
||||
unlinkedSlot = partnerSub?.partnerLicenseKey
|
||||
? await subscriptionRepo.getFirstUnlinkedSlotByLicenseKey(
|
||||
ctx.db,
|
||||
partnerSub.partnerLicenseKey,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (unlinkedSlot) {
|
||||
await Promise.all([
|
||||
subscriptionRepo.updateById(ctx.db, unlinkedSlot.id, {
|
||||
referenceId: workspacePublicId,
|
||||
}),
|
||||
workspaceRepo.update(ctx.db, workspacePublicId, {
|
||||
plan: unlinkedSlot.plan as WorkspacePlan,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
publicId: result.publicId,
|
||||
name: result.name!,
|
||||
slug: result.slug!,
|
||||
description: result.description ?? null,
|
||||
plan: (unlinkedSlot?.plan ?? result.plan!) as WorkspacePlan,
|
||||
cardPrefix: result.cardPrefix!,
|
||||
};
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
@@ -300,7 +344,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 +427,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 +449,23 @@ export const workspaceRouter = createTRPCRouter({
|
||||
});
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
|
||||
|
||||
const result = await workspaceRepo.hardDelete(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||
const subs = await subscriptionRepo.getByReferenceId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
await Promise.all(
|
||||
subs
|
||||
.filter((s) => !!s.partnerLicenseKey)
|
||||
.map((s) =>
|
||||
subscriptionRepo.updateById(ctx.db, s.id, { referenceId: null }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
await workspaceRepo.hardDelete(ctx.db, input.workspacePublicId);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
checkSlugAvailability: publicProcedure
|
||||
.meta({
|
||||
@@ -515,6 +570,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
boardPublicId: z.string(),
|
||||
boardName: z.string(),
|
||||
listName: z.string(),
|
||||
cardNumber: z.number().nullable(),
|
||||
updatedAt: z.date().nullable(),
|
||||
createdAt: z.date(),
|
||||
type: z.literal("card"),
|
||||
@@ -552,4 +608,38 @@ export const workspaceRouter = createTRPCRouter({
|
||||
|
||||
return result;
|
||||
}),
|
||||
hasAvailablePartnerSlot: protectedProcedure
|
||||
.input(z.void())
|
||||
.output(z.boolean())
|
||||
.query(async ({ ctx }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const memberships = await workspaceRepo.getAllByUserId(ctx.db, userId);
|
||||
const workspaceIds = memberships
|
||||
.map((m) => m.workspace?.publicId)
|
||||
.filter((id): id is string => !!id);
|
||||
|
||||
if (!workspaceIds.length) return false;
|
||||
|
||||
const partnerSub =
|
||||
await subscriptionRepo.getFirstActivePartnerSubByWorkspaceIds(
|
||||
ctx.db,
|
||||
workspaceIds,
|
||||
);
|
||||
if (!partnerSub?.partnerLicenseKey) return false;
|
||||
|
||||
const unlinkedSlot =
|
||||
await subscriptionRepo.getFirstUnlinkedSlotByLicenseKey(
|
||||
ctx.db,
|
||||
partnerSub.partnerLicenseKey,
|
||||
);
|
||||
|
||||
return !!unlinkedSlot;
|
||||
}),
|
||||
});
|
||||
|
||||
12
packages/api/src/schemas/attachment.ts
Normal file
12
packages/api/src/schemas/attachment.ts
Normal 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(),
|
||||
});
|
||||
132
packages/api/src/schemas/board.ts
Normal file
132
packages/api/src/schemas/board.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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(),
|
||||
cardNumber: z.number().nullable(),
|
||||
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(),
|
||||
cardPrefix: 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() }),
|
||||
]);
|
||||
219
packages/api/src/schemas/card.ts
Normal file
219
packages/api/src/schemas/card.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
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(),
|
||||
cardNumber: z.number().nullable(),
|
||||
index: z.number(),
|
||||
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(),
|
||||
cardPrefix: 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(),
|
||||
});
|
||||
40
packages/api/src/schemas/common.ts
Normal file
40
packages/api/src/schemas/common.ts
Normal 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(),
|
||||
});
|
||||
39
packages/api/src/schemas/index.ts
Normal file
39
packages/api/src/schemas/index.ts
Normal 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";
|
||||
13
packages/api/src/schemas/list.ts
Normal file
13
packages/api/src/schemas/list.ts
Normal 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(),
|
||||
});
|
||||
6
packages/api/src/schemas/member.ts
Normal file
6
packages/api/src/schemas/member.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ─── member.invite ───────────────────────────────────────────
|
||||
export const memberInviteResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
});
|
||||
96
packages/api/src/schemas/workspace.ts
Normal file
96
packages/api/src/schemas/workspace.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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(),
|
||||
cardPrefix: z.string(),
|
||||
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(),
|
||||
partnerTier: z.number().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"]),
|
||||
cardPrefix: z.string(),
|
||||
});
|
||||
|
||||
// ─── 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(),
|
||||
});
|
||||
@@ -63,6 +63,11 @@ const createAuthWithHeaders = (
|
||||
headers,
|
||||
query: { referenceId: input.workspacePublicId },
|
||||
}),
|
||||
setPassword: (input: { newPassword: string }) =>
|
||||
auth.api.setPassword({
|
||||
headers,
|
||||
body: { newPassword: input.newPassword },
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
@@ -32,6 +43,7 @@ describe("webhook utilities", () => {
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
publicId: "card-pub-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
@@ -44,12 +56,14 @@ describe("webhook utilities", () => {
|
||||
expect(payload.timestamp).toBe("2024-01-15T12:00:00.000Z");
|
||||
expect(payload.data.card).toEqual({
|
||||
id: "card-123",
|
||||
publicId: "card-pub-123",
|
||||
title: "Test Card",
|
||||
description: undefined,
|
||||
dueDate: null,
|
||||
listId: "list-456",
|
||||
boardId: "board-789",
|
||||
});
|
||||
expect(payload.data.card.publicId).toBe("card-pub-123");
|
||||
});
|
||||
|
||||
it("includes optional card fields when provided", () => {
|
||||
@@ -58,6 +72,7 @@ describe("webhook utilities", () => {
|
||||
"card.updated",
|
||||
{
|
||||
id: "card-123",
|
||||
publicId: "card-pub-123",
|
||||
title: "Test Card",
|
||||
description: "A description",
|
||||
dueDate,
|
||||
@@ -77,6 +92,7 @@ describe("webhook utilities", () => {
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
publicId: "card-pub-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
@@ -97,6 +113,7 @@ describe("webhook utilities", () => {
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
publicId: "card-pub-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
@@ -117,6 +134,7 @@ describe("webhook utilities", () => {
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
publicId: "card-pub-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
@@ -140,6 +158,7 @@ describe("webhook utilities", () => {
|
||||
"card.updated",
|
||||
{
|
||||
id: "card-123",
|
||||
publicId: "card-pub-123",
|
||||
title: "Updated Title",
|
||||
listId: "list-456",
|
||||
},
|
||||
@@ -155,6 +174,34 @@ 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",
|
||||
publicId: "card-pub-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", () => {
|
||||
@@ -174,6 +221,7 @@ describe("webhook utilities", () => {
|
||||
data: {
|
||||
card: {
|
||||
id: "card-123",
|
||||
publicId: "card-pub-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
boardId: "board-789",
|
||||
@@ -370,6 +418,7 @@ describe("webhook utilities", () => {
|
||||
data: {
|
||||
card: {
|
||||
id: "card-123",
|
||||
publicId: "card-pub-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
boardId: "board-789",
|
||||
@@ -434,8 +483,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 +509,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 +529,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 +538,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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface WebhookPayload {
|
||||
data: {
|
||||
card: {
|
||||
id: string;
|
||||
publicId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
dueDate?: string | null; // ISO string after JSON serialization
|
||||
@@ -221,6 +222,7 @@ export function createCardWebhookPayload(
|
||||
event: WebhookEventType,
|
||||
card: {
|
||||
id: string;
|
||||
publicId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
dueDate?: Date | null;
|
||||
@@ -243,6 +245,7 @@ export function createCardWebhookPayload(
|
||||
data: {
|
||||
card: {
|
||||
id: card.id,
|
||||
publicId: card.publicId,
|
||||
title: card.title,
|
||||
description: card.description,
|
||||
dueDate: card.dueDate?.toISOString() ?? null,
|
||||
|
||||
40
packages/api/src/utils/workspace.ts
Normal file
40
packages/api/src/utils/workspace.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
export const cancelWorkspaceAccess = async (
|
||||
db: dbClient,
|
||||
workspacePublicId: string,
|
||||
): Promise<void> => {
|
||||
const workspace = await workspaceRepo.getByPublicId(db, workspacePublicId);
|
||||
|
||||
if (!workspace) return;
|
||||
|
||||
const preserveUserId = await memberRepo.getPreservableMemberId(
|
||||
db,
|
||||
workspace.id,
|
||||
workspace.createdBy ?? null,
|
||||
);
|
||||
|
||||
let newSlug = workspace.publicId;
|
||||
if (workspace.slug !== workspace.publicId) {
|
||||
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
|
||||
db,
|
||||
workspace.publicId,
|
||||
);
|
||||
if (!isPublicIdAvailable) {
|
||||
newSlug = generateUID();
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
preserveUserId
|
||||
? memberRepo.pauseMembersExcept(db, workspace.id, preserveUserId)
|
||||
: memberRepo.pauseAllMembers(db, workspace.id),
|
||||
workspaceRepo.update(db, workspacePublicId, {
|
||||
plan: "free",
|
||||
slug: newSlug,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
@@ -10,13 +10,49 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { sendEmail } from "@kan/email";
|
||||
import { createLogger } from "@kan/logger";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
const log = createLogger("auth");
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
import { socialProvidersPlugin } from "./providers";
|
||||
import { triggerWorkflow } from "./utils";
|
||||
|
||||
const log = createLogger("auth");
|
||||
|
||||
async function cancelWorkspaceAccess(
|
||||
db: dbClient,
|
||||
workspacePublicId: string,
|
||||
): Promise<void> {
|
||||
const workspace = await workspaceRepo.getByPublicId(db, workspacePublicId);
|
||||
|
||||
if (!workspace) return;
|
||||
|
||||
const preserveUserId = await memberRepo.getPreservableMemberId(
|
||||
db,
|
||||
workspace.id,
|
||||
workspace.createdBy ?? null,
|
||||
);
|
||||
|
||||
let newSlug = workspace.publicId;
|
||||
if (workspace.slug !== workspace.publicId) {
|
||||
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
|
||||
db,
|
||||
workspace.publicId,
|
||||
);
|
||||
if (!isPublicIdAvailable) {
|
||||
newSlug = generateUID();
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
preserveUserId
|
||||
? memberRepo.pauseMembersExcept(db, workspace.id, preserveUserId)
|
||||
: memberRepo.pauseAllMembers(db, workspace.id),
|
||||
workspaceRepo.update(db, workspacePublicId, {
|
||||
plan: "free",
|
||||
slug: newSlug,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export function createPlugins(db: dbClient) {
|
||||
return [
|
||||
socialProvidersPlugin(),
|
||||
@@ -104,7 +140,10 @@ export function createPlugins(db: dbClient) {
|
||||
unlimitedSeats: true,
|
||||
},
|
||||
);
|
||||
log.info({ subscriptionId: stripeSubscription.id }, "Pro subscription activated with unlimited seats");
|
||||
log.info(
|
||||
{ subscriptionId: stripeSubscription.id },
|
||||
"Pro subscription activated with unlimited seats",
|
||||
);
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
@@ -126,35 +165,9 @@ export function createPlugins(db: dbClient) {
|
||||
subscription,
|
||||
cancellationDetails,
|
||||
);
|
||||
|
||||
// for cancelled subscriptions, we need to pause all members and set their workspace plan to free
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
subscription.referenceId,
|
||||
);
|
||||
|
||||
if (workspace?.id) {
|
||||
await memberRepo.pauseAllMembers(db, workspace.id);
|
||||
|
||||
// Reset slug to publicId, or generate a UID if publicId is taken
|
||||
let newSlug = workspace.publicId;
|
||||
|
||||
if (workspace.slug !== workspace.publicId) {
|
||||
const isPublicIdAvailable =
|
||||
await workspaceRepo.isWorkspaceSlugAvailable(
|
||||
db,
|
||||
workspace.publicId,
|
||||
);
|
||||
if (!isPublicIdAvailable) {
|
||||
newSlug = generateUID();
|
||||
}
|
||||
}
|
||||
|
||||
await workspaceRepo.update(db, subscription.referenceId, {
|
||||
plan: "free",
|
||||
slug: newSlug,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSubscriptionDeleted: async ({ subscription }) => {
|
||||
await cancelWorkspaceAccess(db, subscription.referenceId);
|
||||
},
|
||||
onSubscriptionUpdate: async ({ subscription }) => {
|
||||
await triggerWorkflow(db, "subscription-updated", subscription);
|
||||
@@ -170,7 +183,7 @@ export function createPlugins(db: dbClient) {
|
||||
if (authorization?.startsWith("Bearer ")) {
|
||||
return authorization.slice(7);
|
||||
}
|
||||
return ctx.headers?.get("x-api-key") ?? undefined;
|
||||
return ctx.headers?.get("x-api-key") ?? null;
|
||||
},
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
@@ -183,7 +196,10 @@ export function createPlugins(db: dbClient) {
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
log.info({ email, isInvite: decodedUrl.includes("type=invite") }, "Sending magic link");
|
||||
log.info(
|
||||
{ email, isInvite: decodedUrl.includes("type=invite") },
|
||||
"Sending magic link",
|
||||
);
|
||||
if (decodedUrl.includes("type=invite")) {
|
||||
let inviterName = "";
|
||||
let workspaceName = "";
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user