Compare commits
3 Commits
fix/file-u
...
feat/partn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8863d43793 | ||
|
|
9837ddd8af | ||
|
|
e34518e654 |
@@ -23,8 +23,8 @@ SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
|
||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
||||
|
||||
# S3 storage (optional)
|
||||
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_REGION=
|
||||
S3_ENDPOINT=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_FORCE_PATH_STYLE=
|
||||
|
||||
@@ -231,7 +231,7 @@ We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING
|
||||
|
||||
[<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.
|
||||
Proudly sponsored by [TestMu AI](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 📝
|
||||
|
||||
|
||||
@@ -110,15 +110,13 @@ ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Copy the standalone Next.js server
|
||||
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/standalone/ ./
|
||||
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
||||
# Copy static assets and public files
|
||||
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 --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||
|
||||
# Copy bootstrap script for runtime env var injection
|
||||
COPY --chown=1000:1000 apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||
|
||||
USER 1000:1000
|
||||
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["bootstrap.cjs"]
|
||||
|
||||
@@ -88,12 +88,6 @@ 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
|
||||
@@ -230,8 +224,7 @@ checksums:
|
||||
Z3krJD/origin/0/0: e9da4b3a0eedd68ed126437194a482fe
|
||||
Z3krJD/translation: 0077436d9f37bfd64f3ae076a5a05040
|
||||
WnEwDO/message: 2959fd276248208b65cb27ed46b20135
|
||||
WnEwDO/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
WnEwDO/origin/1/0: d6f5120f072a821219608624f361384e
|
||||
WnEwDO/origin/0/0: d6f5120f072a821219608624f361384e
|
||||
WnEwDO/translation: 2959fd276248208b65cb27ed46b20135
|
||||
j1%2BHPc/message: baf4c3cd580291f41458f9ed28102c49
|
||||
j1%2BHPc/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
@@ -555,11 +548,7 @@ 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
|
||||
@@ -593,9 +582,8 @@ checksums:
|
||||
pFGfsR/origin/0/0: 82383b95848d24b9311ceaef0ec94bb6
|
||||
pFGfsR/translation: b0d0be4f63c16552b3aa795af29240d1
|
||||
5IXWmO/message: e9a430fcd298def74212238df0f680d6
|
||||
5IXWmO/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
|
||||
5IXWmO/origin/1/0: 4a1be3707d14c696a16f453d132fb248
|
||||
5IXWmO/origin/2/0: d6f5120f072a821219608624f361384e
|
||||
5IXWmO/origin/0/0: 4a1be3707d14c696a16f453d132fb248
|
||||
5IXWmO/origin/1/0: d6f5120f072a821219608624f361384e
|
||||
5IXWmO/translation: e9a430fcd298def74212238df0f680d6
|
||||
GnRmu4/message: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||
GnRmu4/origin/0/0: a2dd1fc876bd42b9db7ca7aaa57ac5e2
|
||||
@@ -607,10 +595,6 @@ checksums:
|
||||
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
|
||||
@@ -1013,7 +997,6 @@ 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
|
||||
@@ -1139,9 +1122,9 @@ checksums:
|
||||
rarRW%2F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
rarRW%2F/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
rarRW%2F/translation: 91553b606a6ebed3534cd2e5635ce004
|
||||
sZixXc/translation: db4e3e372abb1ac510c62b14d544f0f8
|
||||
sZixXc/message: db4e3e372abb1ac510c62b14d544f0f8
|
||||
sZixXc/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
sZixXc/translation: db4e3e372abb1ac510c62b14d544f0f8
|
||||
sVBn4K/message: 0d7979a25d290123ed8b8906bf468f1a
|
||||
sVBn4K/origin/0/0: d465da67c0a870d198eb7f313569c137
|
||||
sVBn4K/translation: 0d7979a25d290123ed8b8906bf468f1a
|
||||
@@ -1155,7 +1138,8 @@ checksums:
|
||||
x0kMO%2F/origin/0/0: 30791e26a172d47d3cb0693e82679246
|
||||
x0kMO%2F/translation: 9da6c012e1b512d8c7fbd812626c4675
|
||||
YaZDYI/message: 57cc47581c7faaffb3b1721b6352b576
|
||||
YaZDYI/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
YaZDYI/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
YaZDYI/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
YaZDYI/translation: 57cc47581c7faaffb3b1721b6352b576
|
||||
lsgnzd/message: 934c6867d9bdbe6099b2c0f5a3107f78
|
||||
lsgnzd/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
@@ -1264,9 +1248,6 @@ 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
|
||||
@@ -1490,9 +1471,6 @@ 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
|
||||
@@ -1559,12 +1537,6 @@ 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
|
||||
@@ -1638,17 +1610,11 @@ checksums:
|
||||
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
|
||||
@@ -1781,9 +1747,6 @@ 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
|
||||
@@ -1858,9 +1821,9 @@ checksums:
|
||||
Uworke/message: 213d63da450f35dabb3ab0e35e29feed
|
||||
Uworke/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
Uworke/translation: 213d63da450f35dabb3ab0e35e29feed
|
||||
nG7mus/translation: 03326832c645f4ddb873a6e7eab8a639
|
||||
nG7mus/message: 03326832c645f4ddb873a6e7eab8a639
|
||||
nG7mus/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
nG7mus/translation: 03326832c645f4ddb873a6e7eab8a639
|
||||
IrZaAn/message: 1fcebe9ddb46f722a57f195efddc695d
|
||||
IrZaAn/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
IrZaAn/translation: 1fcebe9ddb46f722a57f195efddc695d
|
||||
@@ -1873,9 +1836,9 @@ checksums:
|
||||
vwGkYB/message: 4c30501d085eaccea47af34212bb26a7
|
||||
vwGkYB/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
vwGkYB/translation: 4c30501d085eaccea47af34212bb26a7
|
||||
Xlkxmi/translation: 3251e7239a9b349a86d5785a113cd6b9
|
||||
Xlkxmi/message: 3251e7239a9b349a86d5785a113cd6b9
|
||||
Xlkxmi/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
Xlkxmi/translation: 3251e7239a9b349a86d5785a113cd6b9
|
||||
lpIMne/message: 37ca1f4e0afc9a0b8e9617f767103c92
|
||||
lpIMne/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
lpIMne/translation: 37ca1f4e0afc9a0b8e9617f767103c92
|
||||
@@ -1920,9 +1883,6 @@ checksums:
|
||||
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
|
||||
@@ -1993,18 +1953,19 @@ checksums:
|
||||
tyHiOa/origin/34/0: afee530613110e248ffde6b56872ebed
|
||||
tyHiOa/origin/35/0: afee530613110e248ffde6b56872ebed
|
||||
tyHiOa/origin/36/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
tyHiOa/origin/37/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
tyHiOa/origin/38/0: 69d8634b177fdf937764b406fd477547
|
||||
tyHiOa/origin/37/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
tyHiOa/origin/38/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
tyHiOa/origin/39/0: 69d8634b177fdf937764b406fd477547
|
||||
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/origin/40/0: 69d8634b177fdf937764b406fd477547
|
||||
tyHiOa/origin/41/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
tyHiOa/origin/42/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
tyHiOa/origin/43/0: 83911e3eacbad4583e2b1647a784d154
|
||||
tyHiOa/origin/44/0: d465da67c0a870d198eb7f313569c137
|
||||
tyHiOa/origin/45/0: 33bf266228f835e12c0bfb0e5efb3be9
|
||||
tyHiOa/origin/46/0: ff48991e314b0b3e989a4dac59860fd5
|
||||
tyHiOa/origin/47/0: 30791e26a172d47d3cb0693e82679246
|
||||
tyHiOa/origin/48/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
tyHiOa/origin/49/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
tyHiOa/translation: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||
PZCqeW/message: 325dea6dd0348a27a6818db2c1340c98
|
||||
PZCqeW/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
@@ -2016,9 +1977,8 @@ checksums:
|
||||
fuwKpE/origin/2/0: fc0c18a095a68277a0f90a115942683b
|
||||
fuwKpE/origin/3/0: 1b05beba6bdd908015fa8e798717f024
|
||||
fuwKpE/origin/4/0: 1b05beba6bdd908015fa8e798717f024
|
||||
fuwKpE/origin/5/0: 1b05beba6bdd908015fa8e798717f024
|
||||
fuwKpE/origin/6/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
fuwKpE/origin/7/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
fuwKpE/origin/5/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
fuwKpE/origin/6/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
fuwKpE/translation: 346a18a54564881c888b95873d03df44
|
||||
nYNMZZ/message: d5a8a1b38eaf02d02cd6ac2211b42a92
|
||||
nYNMZZ/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
@@ -2049,8 +2009,7 @@ checksums:
|
||||
3fPjUY/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
3fPjUY/translation: 682b3c9feab30112b4454cb5bb7974b1
|
||||
oyRjD%2F/message: 76a09fd843b496610f8cf1dced417b62
|
||||
oyRjD%2F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
oyRjD%2F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
oyRjD%2F/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
oyRjD%2F/translation: 76a09fd843b496610f8cf1dced417b62
|
||||
Qtzfdk/message: a898be9e4610706b9b8146a209e8ba16
|
||||
Qtzfdk/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
@@ -2194,13 +2153,6 @@ 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
|
||||
@@ -2244,20 +2196,20 @@ checksums:
|
||||
v3YoMA/message: 3fff865dc00435f82896fc302ea45630
|
||||
v3YoMA/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
v3YoMA/translation: 3fff865dc00435f82896fc302ea45630
|
||||
GXGbXi/translation: 8686cb61f9bd3cd7ed100cc3af819458
|
||||
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/translation: 873c4eb14a7e32e7fd7793047b6028a1
|
||||
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
|
||||
@@ -2298,13 +2250,6 @@ checksums:
|
||||
n1ekoW/message: ec7b8f314fe9bc6591006707484ede61
|
||||
n1ekoW/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
n1ekoW/translation: ec7b8f314fe9bc6591006707484ede61
|
||||
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
|
||||
@@ -2347,12 +2292,6 @@ checksums:
|
||||
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
|
||||
@@ -2360,13 +2299,9 @@ checksums:
|
||||
R6pB8R/origin/0/0: 09a6a32f9a6b415e81b4d2f8181a0e6b
|
||||
R6pB8R/translation: e5cb0428f1ac6485688f120d78742875
|
||||
veIDCY/message: 4be2811a8f3455618032ccb59e40658b
|
||||
veIDCY/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
veIDCY/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
veIDCY/origin/1/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
|
||||
@@ -2401,8 +2336,7 @@ checksums:
|
||||
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
|
||||
bff61F/message: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||
bff61F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
bff61F/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
bff61F/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
bff61F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
bff61F/translation: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||
CAL6E9/message: b63448c05270497973ac4407047dae02
|
||||
CAL6E9/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
@@ -2453,9 +2387,6 @@ 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
|
||||
@@ -2531,9 +2462,6 @@ 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
|
||||
@@ -2642,7 +2570,6 @@ checksums:
|
||||
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
|
||||
@@ -2754,9 +2681,6 @@ 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: c0b59968b2b234167c488a05cc38710b
|
||||
mx8%2B1u/origin/1/0: c0b59968b2b234167c488a05cc38710b
|
||||
@@ -2766,9 +2690,6 @@ checksums:
|
||||
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
|
||||
@@ -2802,12 +2723,10 @@ 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: dbad0c8d7863cb41f5495264f82e7081
|
||||
IelE1h/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
IelE1h/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
IelE1h/origin/2/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
IelE1h/translation: 972773025e763ddf53273df6d37a729a
|
||||
cWtfn1/message: 9a91ea70f5eb55aac96640bf0fd7feff
|
||||
cWtfn1/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
@@ -2880,10 +2799,6 @@ 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
|
||||
@@ -3055,22 +2970,18 @@ checksums:
|
||||
MdN5zD/message: e2e816f2b7a13b1056d79e7f25088664
|
||||
MdN5zD/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
MdN5zD/translation: e2e816f2b7a13b1056d79e7f25088664
|
||||
ZhQoZn/translation: b05a1191bf048d76e7d6c1c3e76ce1fd
|
||||
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/translation: 65609a209e74f230563a7c8d7258a83c
|
||||
8Zjc7q/message: 65609a209e74f230563a7c8d7258a83c
|
||||
8Zjc7q/origin/0/0: 278a40eb45b1219f424703d0abc1d273
|
||||
8Zjc7q/translation: 65609a209e74f230563a7c8d7258a83c
|
||||
GoDLB9/message: 8c8d944e07388c5877effdb2c2803dcf
|
||||
GoDLB9/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
GoDLB9/translation: 8c8d944e07388c5877effdb2c2803dcf
|
||||
@@ -3095,18 +3006,12 @@ 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/translation: d262a1ba9ebeea549d982f9cf85a6c18
|
||||
EuK5ez/message: d262a1ba9ebeea549d982f9cf85a6c18
|
||||
EuK5ez/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
EuK5ez/translation: d262a1ba9ebeea549d982f9cf85a6c18
|
||||
"%2FtOdd2/message": 099e1780348cd7bf617721344371a430
|
||||
"%2FtOdd2/origin/0/0": 03c00909b41be01231cd3e843c8ee44e
|
||||
"%2FtOdd2/translation": 099e1780348cd7bf617721344371a430
|
||||
|
||||
@@ -46,7 +46,6 @@ interface FormValues {
|
||||
interface AuthProps {
|
||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||
isSignUp?: boolean;
|
||||
callbackURL?: string;
|
||||
}
|
||||
|
||||
const EmailSchema = z.object({
|
||||
@@ -153,11 +152,7 @@ const availableSocialProviders = {
|
||||
},
|
||||
};
|
||||
|
||||
export function Auth({
|
||||
setIsMagicLinkSent,
|
||||
isSignUp,
|
||||
callbackURL: callbackURLProp,
|
||||
}: AuthProps) {
|
||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
const [isCloudEnv, setIsCloudEnv] = useState(false);
|
||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||
useState<null | AuthProvider>(null);
|
||||
@@ -170,7 +165,7 @@ export function Auth({
|
||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const redirect = useSearchParams().get("next");
|
||||
const callbackURL = callbackURLProp ?? redirect ?? "/boards";
|
||||
const callbackURL = redirect ?? "/boards";
|
||||
|
||||
// Safely get environment variables on client side to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useTheme } from "next-themes";
|
||||
@@ -14,7 +14,6 @@ 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";
|
||||
@@ -50,9 +49,7 @@ export default function Dashboard({
|
||||
const { resolvedTheme } = useTheme();
|
||||
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(
|
||||
@@ -106,38 +103,6 @@ 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") {
|
||||
|
||||
@@ -474,7 +474,7 @@ export default function Editor({
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
Markdown.configure({ transformPastedText: true }),
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
|
||||
@@ -96,7 +96,6 @@ export function NewWorkspaceForm() {
|
||||
description: values.description,
|
||||
slug: values.slug,
|
||||
plan: values.plan,
|
||||
cardPrefix: values.cardPrefix,
|
||||
role: "admin",
|
||||
weekStartDay: 1,
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ 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";
|
||||
|
||||
@@ -52,6 +53,7 @@ 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 },
|
||||
@@ -221,19 +223,21 @@ export default function SideNavigation({
|
||||
<ButtonComponent
|
||||
iconLeft={<HiBolt />}
|
||||
variant="secondary"
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
|
||||
aria-label={t`Start free trial`}
|
||||
title={t`Start free trial`}
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
aria-label="Upgrade to Pro"
|
||||
title="Upgrade to Pro"
|
||||
iconOnly
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
/>
|
||||
) : (
|
||||
<ButtonComponent
|
||||
iconLeft={<HiBolt />}
|
||||
fullWidth
|
||||
variant="secondary"
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
>
|
||||
{t`Start free trial`}
|
||||
{t`Upgrade to Pro`}
|
||||
</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,7 +9,6 @@ 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";
|
||||
|
||||
@@ -21,8 +20,6 @@ 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);
|
||||
|
||||
@@ -153,19 +150,11 @@ export default function WorkspaceMenu({
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onClick={() =>
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`)
|
||||
: openModal("NEW_WORKSPACE")
|
||||
}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Create workspace`}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -4,13 +4,12 @@ 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 },
|
||||
1: { plan: "team", seats: 5, unlimitedSeats: false },
|
||||
2: { plan: "pro", seats: 15, unlimitedSeats: false },
|
||||
3: { plan: "pro", seats: null, unlimitedSeats: true },
|
||||
};
|
||||
|
||||
export function tierConfig(tier: number): TierConfig {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
import { tierConfig } from "./_utils";
|
||||
@@ -92,7 +93,7 @@ export default withRateLimit(
|
||||
const { code } = req.query;
|
||||
|
||||
if (!code || typeof code !== "string") {
|
||||
return res.status(200).json({ message: "OK" });
|
||||
return res.status(400).json({ message: "Missing code parameter" });
|
||||
}
|
||||
|
||||
let license: LicenseDetailResponse;
|
||||
@@ -109,15 +110,11 @@ export default withRateLimit(
|
||||
const { db, user } = await createNextApiContext(req);
|
||||
|
||||
const cfg = tierConfig(license.tier);
|
||||
const status = license.status === "active" ? "active" : "inactive";
|
||||
const isActive = license.status === "active";
|
||||
const status = isActive ? "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(
|
||||
if (!user) {
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(
|
||||
db,
|
||||
license.license_key,
|
||||
{
|
||||
@@ -127,18 +124,45 @@ export default withRateLimit(
|
||||
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)}`,
|
||||
);
|
||||
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
const workspace = memberships?.[0]?.workspace;
|
||||
|
||||
if (!workspace) {
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(
|
||||
db,
|
||||
license.license_key,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: license.tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
);
|
||||
return res.redirect(
|
||||
`/onboarding?license_key=${encodeURIComponent(license.license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license.license_key, {
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: license.tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
referenceId: workspace.publicId,
|
||||
});
|
||||
|
||||
if (isActive) {
|
||||
await workspaceRepo.update(db, workspace.publicId, { plan: cfg.plan });
|
||||
}
|
||||
|
||||
return res.redirect(`/?partner_activated=1`);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ export default withRateLimit(
|
||||
const { license_key } = req.query;
|
||||
|
||||
if (!license_key || typeof license_key !== "string") {
|
||||
return res.redirect("/boards?partner_error=missing_license");
|
||||
return res.redirect("/?partner_error=missing_license");
|
||||
}
|
||||
|
||||
const { db, user } = await createNextApiContext(req);
|
||||
@@ -27,72 +27,38 @@ export default withRateLimit(
|
||||
);
|
||||
}
|
||||
|
||||
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key);
|
||||
|
||||
if (!allSlots.length) {
|
||||
return res.redirect("/boards?partner_error=invalid_license");
|
||||
if (!sub) {
|
||||
return res.redirect("/?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");
|
||||
if (sub.status !== "active") {
|
||||
return res.redirect("/?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));
|
||||
const workspace = memberships?.[0]?.workspace;
|
||||
|
||||
if (!availableWorkspace) {
|
||||
if (!workspace) {
|
||||
return res.redirect(
|
||||
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
|
||||
`/onboarding?license_key=${encodeURIComponent(license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await subscriptionRepo.updateById(db, unlinkedSlot.id, {
|
||||
referenceId: availableWorkspace.publicId,
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: sub.plan,
|
||||
status: sub.status,
|
||||
partnerTier: sub.partnerTier ?? 1,
|
||||
seats: sub.seats ?? null,
|
||||
unlimitedSeats: sub.unlimitedSeats,
|
||||
referenceId: workspace.publicId,
|
||||
});
|
||||
|
||||
await workspaceRepo.update(db, availableWorkspace.publicId, {
|
||||
plan: unlinkedSlot.plan as "free" | "team" | "pro" | "enterprise",
|
||||
await workspaceRepo.update(db, workspace.publicId, {
|
||||
plan: sub.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");
|
||||
return res.redirect("/?partner_activated=1");
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2,112 +2,16 @@ 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) {
|
||||
@@ -125,9 +29,9 @@ function verifySignature(
|
||||
if (!secret) return false;
|
||||
|
||||
const ts = Number(timestamp);
|
||||
if (isNaN(ts) || Date.now() - ts > 300_000) return false;
|
||||
if (isNaN(ts) || Date.now() / 1000 - ts > 300) return false;
|
||||
|
||||
const payload = `${timestamp}${rawBody}`;
|
||||
const payload = `${timestamp}.${rawBody}`;
|
||||
const expected = createHmac("sha256", secret).update(payload).digest("hex");
|
||||
|
||||
try {
|
||||
@@ -137,12 +41,6 @@ function verifySignature(
|
||||
}
|
||||
}
|
||||
|
||||
function hasReferenceId<T extends { referenceId: string | null | undefined }>(
|
||||
s: T,
|
||||
): s is T & { referenceId: string } {
|
||||
return !!s.referenceId;
|
||||
}
|
||||
|
||||
interface WebhookPayload {
|
||||
event:
|
||||
| "purchase"
|
||||
@@ -189,217 +87,93 @@ export default withApiLogging(
|
||||
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 "purchase": {
|
||||
const cfg = tierConfig(tier);
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status: license_status,
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "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,
|
||||
});
|
||||
}
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "deactivate": {
|
||||
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
||||
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,
|
||||
});
|
||||
|
||||
if (sub) {
|
||||
await subscriptionRepo.updateById(db, sub.id, {
|
||||
plan: "free",
|
||||
status: "inactive",
|
||||
});
|
||||
if (sub.referenceId) {
|
||||
await workspaceRepo.update(db, sub.referenceId, { plan: "free" });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "upgrade":
|
||||
case "downgrade": {
|
||||
const lookupKey = prev_license_key ?? license_key;
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
||||
db,
|
||||
lookupKey,
|
||||
license_key,
|
||||
);
|
||||
|
||||
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 (sub) {
|
||||
const cfg = tierConfig(tier);
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status: license_status,
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
});
|
||||
if (sub.referenceId) {
|
||||
await workspaceRepo.update(db, sub.referenceId, { plan: cfg.plan });
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
if (prev_license_key) {
|
||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
||||
db,
|
||||
prev_license_key,
|
||||
);
|
||||
if (sub) {
|
||||
const cfg = tierConfig(tier);
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
status: sub.status,
|
||||
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,
|
||||
);
|
||||
referenceId: sub.referenceId ?? undefined,
|
||||
});
|
||||
await subscriptionRepo.updateById(db, sub.id, {
|
||||
status: "inactive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,10 +64,7 @@ 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;
|
||||
@@ -93,10 +90,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,15 +67,8 @@ export default withRateLimit(
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const rawFilenameHeader =
|
||||
const originalFilenameHeader =
|
||||
(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,15 +64,8 @@ export default withRateLimit(
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const rawFilenameHeader =
|
||||
const originalFilenameHeader =
|
||||
(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, "_")
|
||||
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -17,18 +16,10 @@ export default function PartnerActivatePage() {
|
||||
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(
|
||||
@@ -58,11 +49,6 @@ export default function PartnerActivatePage() {
|
||||
<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`
|
||||
)}
|
||||
@@ -82,11 +68,6 @@ export default function PartnerActivatePage() {
|
||||
setIsMagicLinkSent(val);
|
||||
setMagicLinkRecipient(recipient);
|
||||
}}
|
||||
callbackURL={
|
||||
licenseKey
|
||||
? `/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`
|
||||
: "/boards"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -58,12 +58,9 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const pollAttemptsRef = React.useRef(0);
|
||||
const MAX_POLL_ATTEMPTS = 5;
|
||||
|
||||
const { data, isLoading, isFetching } = api.workspace.all.useQuery(
|
||||
undefined,
|
||||
{
|
||||
refetchInterval: pendingWorkspaceId ? 2000 : false,
|
||||
},
|
||||
);
|
||||
const { data, isLoading } = api.workspace.all.useQuery(undefined, {
|
||||
refetchInterval: pendingWorkspaceId ? 2000 : false,
|
||||
});
|
||||
const utils = api.useUtils();
|
||||
|
||||
const switchWorkspace = (_workspace: Workspace) => {
|
||||
@@ -79,7 +76,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.length) {
|
||||
if (!isLoading && !isFetching) setHasLoaded(true);
|
||||
if (!isLoading) setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,72 +100,55 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
|
||||
if (storedWorkspaceId !== null) {
|
||||
const selectedWorkspace = data.find(
|
||||
const newData = data;
|
||||
const selectedWorkspace = newData.find(
|
||||
({ workspace }) => workspace.publicId === storedWorkspaceId,
|
||||
);
|
||||
|
||||
if (!selectedWorkspace?.workspace) {
|
||||
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");
|
||||
pollAttemptsRef.current += 1;
|
||||
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
||||
setPendingWorkspaceId(null);
|
||||
}
|
||||
} else {
|
||||
pollAttemptsRef.current = 0;
|
||||
setPendingWorkspaceId(null);
|
||||
|
||||
setWorkspace({
|
||||
publicId: selectedWorkspace.workspace.publicId,
|
||||
name: selectedWorkspace.workspace.name,
|
||||
slug: selectedWorkspace.workspace.slug,
|
||||
plan: selectedWorkspace.workspace.plan,
|
||||
description: selectedWorkspace.workspace.description,
|
||||
role: selectedWorkspace.role as "admin" | "member" | "guest",
|
||||
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||
cardPrefix: selectedWorkspace.workspace.cardPrefix,
|
||||
});
|
||||
|
||||
if (workspacePublicId) {
|
||||
router.push(`/boards`);
|
||||
localStorage.setItem("workspacePublicId", workspacePublicId);
|
||||
}
|
||||
|
||||
setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
pollAttemptsRef.current = 0;
|
||||
setPendingWorkspaceId(null);
|
||||
|
||||
setWorkspace({
|
||||
publicId: selectedWorkspace.workspace.publicId,
|
||||
name: selectedWorkspace.workspace.name,
|
||||
slug: selectedWorkspace.workspace.slug,
|
||||
plan: selectedWorkspace.workspace.plan,
|
||||
description: selectedWorkspace.workspace.description,
|
||||
role: selectedWorkspace.role,
|
||||
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||
cardPrefix: selectedWorkspace.workspace.cardPrefix,
|
||||
});
|
||||
|
||||
if (workspacePublicId) {
|
||||
router.push(`/boards`);
|
||||
localStorage.setItem("workspacePublicId", workspacePublicId);
|
||||
}
|
||||
} else {
|
||||
const primaryWorkspace = data[0]?.workspace;
|
||||
const primaryWorkspaceRole = data[0]?.role;
|
||||
|
||||
if (!primaryWorkspace || !primaryWorkspaceRole) return;
|
||||
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
|
||||
setWorkspace({
|
||||
publicId: primaryWorkspace.publicId,
|
||||
name: primaryWorkspace.name,
|
||||
slug: primaryWorkspace.slug,
|
||||
plan: primaryWorkspace.plan,
|
||||
description: primaryWorkspace.description,
|
||||
role: primaryWorkspaceRole,
|
||||
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
||||
cardPrefix: primaryWorkspace.cardPrefix,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}, [data, isLoading, workspacePublicId, router]);
|
||||
|
||||
return (
|
||||
<WorkspaceContext.Provider
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function LoginPage() {
|
||||
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
if (data?.user.id) router.push(redirect ?? "/boards");
|
||||
if (data?.user.id) router.push("/boards");
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function SignUpPage() {
|
||||
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
if (data?.user.id) router.push(redirect ?? "/boards");
|
||||
if (data?.user.id) router.push("/boards");
|
||||
|
||||
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
||||
setIsMagicLinkSent(value);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -8,6 +7,7 @@ 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": encodeURIComponent(file.name),
|
||||
"x-original-filename": file.name,
|
||||
},
|
||||
body: file,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
HiHashtag,
|
||||
HiLink,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineDocumentDuplicate,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
@@ -14,7 +13,6 @@ 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,
|
||||
@@ -22,44 +20,19 @@ export default function CardDropdown({
|
||||
boardPublicId,
|
||||
cardCreatedBy,
|
||||
ticketNumber,
|
||||
listPublicId,
|
||||
cardIndex,
|
||||
}: {
|
||||
cardPublicId: string;
|
||||
isTemplate?: boolean;
|
||||
boardPublicId?: string;
|
||||
cardCreatedBy?: string | null;
|
||||
ticketNumber?: string | null;
|
||||
listPublicId?: string;
|
||||
cardIndex?: number;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditCard, canDeleteCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const utils = api.useUtils();
|
||||
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
||||
|
||||
const duplicateCard = api.card.duplicate.useMutation({
|
||||
onSuccess: () => {
|
||||
showPopup({
|
||||
header: t`Card duplicated`,
|
||||
icon: "success",
|
||||
message: t`Card duplicated successfully.`,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to duplicate card`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopyCardLink = async () => {
|
||||
const path =
|
||||
isTemplate && boardPublicId
|
||||
@@ -126,24 +99,6 @@ 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,11 +185,7 @@ 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 },
|
||||
);
|
||||
@@ -344,15 +340,14 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
>
|
||||
{board?.name}
|
||||
</Link>
|
||||
{card.cardNumber != null &&
|
||||
card.list.board.workspace.cardPrefix && (
|
||||
<>
|
||||
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
|
||||
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
|
||||
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{card.cardNumber != null && card.list.board.workspace.cardPrefix && (
|
||||
<>
|
||||
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
|
||||
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
|
||||
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown
|
||||
@@ -361,13 +356,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
boardPublicId={boardId}
|
||||
cardCreatedBy={card?.createdBy}
|
||||
ticketNumber={
|
||||
card.cardNumber != null &&
|
||||
card.list.board.workspace.cardPrefix
|
||||
card.cardNumber != null && card.list.board.workspace.cardPrefix
|
||||
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
|
||||
: null
|
||||
}
|
||||
listPublicId={card?.list.publicId}
|
||||
cardIndex={card?.index}
|
||||
/>
|
||||
<Link
|
||||
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
|
||||
|
||||
@@ -36,17 +36,6 @@ 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,6 +13,7 @@ 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";
|
||||
@@ -24,15 +25,15 @@ import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function InviteMemberForm({
|
||||
numberOfMembers,
|
||||
subscriptions,
|
||||
unlimitedSeats,
|
||||
memberCount,
|
||||
seatLimit,
|
||||
userId,
|
||||
}: {
|
||||
numberOfMembers: number;
|
||||
subscriptions: Subscription[] | undefined;
|
||||
unlimitedSeats: boolean;
|
||||
memberCount: number;
|
||||
seatLimit: number | null;
|
||||
userId: string | undefined;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
|
||||
@@ -97,15 +98,6 @@ 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`,
|
||||
@@ -161,9 +153,6 @@ export function InviteMemberForm({
|
||||
|
||||
const hasTeamSubscription = !!teamSubscription;
|
||||
const hasProSubscription = !!proSubscription;
|
||||
const isPartnerTier = !!(
|
||||
teamSubscription?.partnerTier ?? proSubscription?.partnerTier
|
||||
);
|
||||
|
||||
let isYearly = false;
|
||||
let price = t`$10/month`;
|
||||
@@ -186,13 +175,13 @@ export function InviteMemberForm({
|
||||
inviteMember.mutate(member);
|
||||
};
|
||||
|
||||
const isFreePlan =
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription;
|
||||
|
||||
const handleInviteLinkToggle = async () => {
|
||||
if (isFreePlan) return;
|
||||
if (
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
)
|
||||
return handleUpgrade();
|
||||
|
||||
setIsLoadingInviteLink(true);
|
||||
|
||||
@@ -230,6 +219,31 @@ 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");
|
||||
@@ -256,7 +270,11 @@ export function InviteMemberForm({
|
||||
<Input
|
||||
id="email"
|
||||
placeholder={t`Email`}
|
||||
disabled={isFreePlan}
|
||||
disabled={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
}
|
||||
{...register("email", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -267,92 +285,78 @@ export function InviteMemberForm({
|
||||
errorMessage={errors.email?.message}
|
||||
/>
|
||||
)}
|
||||
{!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`}
|
||||
{(!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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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`}
|
||||
</span>
|
||||
<span className="text-light-900 dark:text-dark-900">
|
||||
{memberCount} / {seatLimit} {t`seats`}
|
||||
) : (
|
||||
<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 Plan. You'll be redirected to upgrade your workspace.`}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
<Toggle
|
||||
label={
|
||||
isShareInviteLinkEnabled
|
||||
? t`Deactivate invite link`
|
||||
: t`Create invite link`
|
||||
}
|
||||
isChecked={isShareInviteLinkEnabled}
|
||||
onChange={handleInviteLinkToggle}
|
||||
/>
|
||||
<div>
|
||||
{isFreePlan ? (
|
||||
<Button
|
||||
type="button"
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
|
||||
>
|
||||
{t`Choose plan`}
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription ? (
|
||||
<Button type="button" onClick={handleUpgrade}>
|
||||
{t`Start 14 day free trial`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -11,11 +11,7 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import {
|
||||
getSeatLimit,
|
||||
getSubscriptionByPlan,
|
||||
hasUnlimitedSeats,
|
||||
} from "@kan/shared/utils";
|
||||
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
@@ -31,8 +27,8 @@ import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
|
||||
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
|
||||
import { InviteMemberForm } from "./components/InviteMemberForm";
|
||||
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
|
||||
|
||||
export default function MembersPage() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
@@ -80,23 +76,6 @@ 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,
|
||||
@@ -165,8 +144,8 @@ export default function MembersPage() {
|
||||
{memberName}
|
||||
</p>
|
||||
</div>
|
||||
{(workspace.role === "admin" ||
|
||||
data?.showEmailsToMembers === true ||
|
||||
{((workspace.role === "admin" ||
|
||||
data?.showEmailsToMembers === true) ||
|
||||
showSkeleton) && (
|
||||
<p
|
||||
className={twMerge(
|
||||
@@ -201,7 +180,8 @@ 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" />
|
||||
)}
|
||||
@@ -284,42 +264,38 @@ export default function MembersPage() {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && !!data && (
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<>
|
||||
{!isPaidPlan && (
|
||||
{!proSubscription && !teamSubscription && (
|
||||
<Link
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
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`Upgrade`}</span>
|
||||
<span className="ml-1 font-medium">
|
||||
{t`Launch offer: Get unlimited members with Pro`}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center rounded-full border px-3 py-1 text-center text-xs",
|
||||
isPaidPlan
|
||||
teamSubscription || proSubscription
|
||||
? "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">
|
||||
{isProPlan
|
||||
{proSubscription
|
||||
? t`Pro Plan`
|
||||
: isTeamPlan
|
||||
: teamSubscription
|
||||
? 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
|
||||
@@ -408,10 +384,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 { Radio, RadioGroup } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Radio, RadioGroup } from "@headlessui/react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { HiUser } from "react-icons/hi2";
|
||||
@@ -48,7 +48,6 @@ 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, {
|
||||
@@ -97,44 +96,18 @@ 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(buildSelectPlanUrl(plan, billing));
|
||||
router.replace(`/onboarding/select-plan?plan=${plan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
|
||||
};
|
||||
|
||||
const handleSetBilling = (b: Billing) => {
|
||||
setBilling(b);
|
||||
router.replace(buildSelectPlanUrl(selected, b));
|
||||
router.replace(`/onboarding/select-plan?plan=${selected}&billing=${b}&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 handleContinue = () =>
|
||||
router.push(`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
|
||||
|
||||
const handleCancel = () => router.push(returnUrl);
|
||||
|
||||
@@ -236,11 +209,7 @@ export default function SelectPlanView() {
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => void handleContinue()}>
|
||||
{workspacePublicId && selected !== "solo"
|
||||
? t`Upgrade`
|
||||
: t`Continue`}
|
||||
</Button>
|
||||
<Button onClick={handleContinue}>{t`Continue`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,17 +39,7 @@ 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;
|
||||
|
||||
@@ -104,16 +94,7 @@ export default function WorkspaceNameView() {
|
||||
if (!workspace.publicId) return;
|
||||
localStorage.setItem("workspacePublicId", workspace.publicId);
|
||||
void utils.workspace.all.invalidate();
|
||||
void utils.workspace.hasAvailablePartnerSlot.invalidate();
|
||||
const storedLicenseKey = localStorage.getItem("partnerLicenseKey");
|
||||
if (storedLicenseKey) {
|
||||
localStorage.removeItem("partnerLicenseKey");
|
||||
router.push(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(storedLicenseKey)}`,
|
||||
);
|
||||
} else {
|
||||
router.push("/boards");
|
||||
}
|
||||
router.push("/boards");
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
@@ -129,7 +110,7 @@ export default function WorkspaceNameView() {
|
||||
const handleContinue = async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
if (effectivePlan === "solo" || isLicenseFlow) {
|
||||
if (effectivePlan === "solo") {
|
||||
createWorkspace.mutate({
|
||||
name: name.trim(),
|
||||
...(description.trim() && { description: description.trim() }),
|
||||
@@ -212,52 +193,50 @@ export default function WorkspaceNameView() {
|
||||
maxLength={64}
|
||||
/>
|
||||
|
||||
{!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
|
||||
<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
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isLicenseFlow && plan !== "pro" && (
|
||||
{plan !== "pro" && (
|
||||
<div className="pb-2">
|
||||
<Toggle
|
||||
isChecked={isProToggle}
|
||||
@@ -286,18 +265,16 @@ export default function WorkspaceNameView() {
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="ml-auto flex gap-2">
|
||||
{!isLicenseFlow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
router.replace(
|
||||
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Back`}
|
||||
</Button>
|
||||
)}
|
||||
<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,28 +95,8 @@ 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-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">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCardLink}
|
||||
@@ -154,6 +134,22 @@ export function CardModal({
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data?.cardNumber != null && data.list.board.workspace.cardPrefix && (
|
||||
<span className="mb-1 block text-xs font-medium text-light-700 dark:text-dark-800">
|
||||
{data.list.board.workspace.cardPrefix}-{data.cardNumber}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{data?.title}
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{labels.length > 0 && (
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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";
|
||||
@@ -9,49 +7,24 @@ 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);
|
||||
}
|
||||
@@ -61,28 +34,6 @@ 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,6 +1,11 @@
|
||||
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,16 +22,38 @@ import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescript
|
||||
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
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`} />
|
||||
@@ -80,6 +107,19 @@ 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`}
|
||||
@@ -106,6 +146,16 @@ 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": encodeURIComponent(fileName),
|
||||
"x-original-filename": fileName,
|
||||
},
|
||||
body: blob,
|
||||
},
|
||||
|
||||
@@ -99,19 +99,6 @@ 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
|
||||
|
||||
@@ -5,40 +5,36 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./root": {
|
||||
"types": "./src/root.ts",
|
||||
"types": "./dist/root.d.ts",
|
||||
"default": "./src/root.ts"
|
||||
},
|
||||
"./trpc": {
|
||||
"types": "./src/trpc.ts",
|
||||
"types": "./dist/trpc.d.ts",
|
||||
"default": "./src/trpc.ts"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./src/types/router.types.ts",
|
||||
"types": "./dist/types.d.ts",
|
||||
"default": "./src/types/router.types.ts"
|
||||
},
|
||||
"./openapi": {
|
||||
"types": "./src/openapi.ts",
|
||||
"types": "./dist/openapi.d.ts",
|
||||
"default": "./src/openapi.ts"
|
||||
},
|
||||
"./utils/rateLimit": {
|
||||
"types": "./src/utils/rateLimit.ts",
|
||||
"types": "./dist/utils/rateLimit.d.ts",
|
||||
"default": "./src/utils/rateLimit.ts"
|
||||
},
|
||||
"./utils/apiLogging": {
|
||||
"types": "./src/utils/apiLogging.ts",
|
||||
"types": "./dist/utils/apiLogging.d.ts",
|
||||
"default": "./src/utils/apiLogging.ts"
|
||||
},
|
||||
"./utils/permissions": {
|
||||
"types": "./src/utils/permissions.ts",
|
||||
"types": "./dist/utils/permissions.d.ts",
|
||||
"default": "./src/utils/permissions.ts"
|
||||
},
|
||||
"./utils/workspace": {
|
||||
"types": "./src/utils/workspace.ts",
|
||||
"default": "./src/utils/workspace.ts"
|
||||
}
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
|
||||
@@ -1347,7 +1347,7 @@ export const cardRouter = createTRPCRouter({
|
||||
checklistId: newChecklist.id,
|
||||
title: item.title,
|
||||
createdBy: userId,
|
||||
completed: false,
|
||||
completed: item.completed ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +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 { memberInviteResponseSchema } from "../schemas";
|
||||
import {
|
||||
assertCanManageMember,
|
||||
assertCanManageRole,
|
||||
@@ -115,20 +114,6 @@ 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);
|
||||
@@ -659,20 +644,6 @@ 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,21 +2,19 @@ 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 {
|
||||
workspaceListItemSchema,
|
||||
workspaceDetailSchema,
|
||||
workspaceWithBoardsSchema,
|
||||
workspaceCreateResponseSchema,
|
||||
workspaceUpdateResponseSchema,
|
||||
workspaceDeleteResponseSchema,
|
||||
} from "../schemas";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
|
||||
export const workspaceRouter = createTRPCRouter({
|
||||
@@ -272,48 +270,12 @@ export const workspaceRouter = createTRPCRouter({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
let unlinkedSlot: Awaited<
|
||||
ReturnType<typeof subscriptionRepo.getFirstUnlinkedSlotByLicenseKey>
|
||||
>;
|
||||
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||
const memberships = await workspaceRepo.getAllByUserId(ctx.db, userId);
|
||||
const otherWorkspaceIds = memberships
|
||||
.map((m) => m.workspace?.publicId)
|
||||
.filter((id): id is string => !!id && id !== workspacePublicId);
|
||||
|
||||
const partnerSub = otherWorkspaceIds.length
|
||||
? await subscriptionRepo.getFirstActivePartnerSubByWorkspaceIds(
|
||||
ctx.db,
|
||||
otherWorkspaceIds,
|
||||
)
|
||||
: undefined;
|
||||
unlinkedSlot = partnerSub?.partnerLicenseKey
|
||||
? await subscriptionRepo.getFirstUnlinkedSlotByLicenseKey(
|
||||
ctx.db,
|
||||
partnerSub.partnerLicenseKey,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (unlinkedSlot) {
|
||||
await Promise.all([
|
||||
subscriptionRepo.updateById(ctx.db, unlinkedSlot.id, {
|
||||
referenceId: workspacePublicId,
|
||||
}),
|
||||
workspaceRepo.update(ctx.db, workspacePublicId, {
|
||||
plan: unlinkedSlot.plan as WorkspacePlan,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
publicId: result.publicId,
|
||||
name: result.name!,
|
||||
slug: result.slug!,
|
||||
description: result.description ?? null,
|
||||
plan: (unlinkedSlot?.plan ?? result.plan!) as WorkspacePlan,
|
||||
cardPrefix: result.cardPrefix!,
|
||||
plan: result.plan!,
|
||||
};
|
||||
}),
|
||||
update: protectedProcedure
|
||||
@@ -449,21 +411,10 @@ export const workspaceRouter = createTRPCRouter({
|
||||
});
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
|
||||
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||
const subs = await subscriptionRepo.getByReferenceId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
await Promise.all(
|
||||
subs
|
||||
.filter((s) => !!s.partnerLicenseKey)
|
||||
.map((s) =>
|
||||
subscriptionRepo.updateById(ctx.db, s.id, { referenceId: null }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await workspaceRepo.hardDelete(ctx.db, input.workspacePublicId);
|
||||
await workspaceRepo.hardDelete(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
@@ -608,38 +559,4 @@ 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;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -39,7 +39,6 @@ const boardDetailCardSchema = z.object({
|
||||
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),
|
||||
@@ -58,7 +57,6 @@ export const boardDetailSchema = z.object({
|
||||
favorite: z.boolean(),
|
||||
workspace: z.object({
|
||||
publicId: z.string(),
|
||||
cardPrefix: z.string(),
|
||||
members: z.array(workspaceMemberSchema),
|
||||
}),
|
||||
labels: z.array(labelSchema),
|
||||
|
||||
@@ -46,8 +46,6 @@ 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),
|
||||
@@ -77,7 +75,6 @@ export const cardDetailSchema = z.object({
|
||||
),
|
||||
workspace: z.object({
|
||||
publicId: z.string(),
|
||||
cardPrefix: z.string(),
|
||||
members: z.array(workspaceMemberSchema),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -12,7 +12,6 @@ export const workspaceListItemSchema = z.object({
|
||||
slug: z.string(),
|
||||
plan: z.enum(["free", "team", "pro", "enterprise"]),
|
||||
weekStartDay: z.number().nullable(),
|
||||
cardPrefix: z.string(),
|
||||
deletedAt: z.date().nullable(),
|
||||
}),
|
||||
});
|
||||
@@ -39,7 +38,6 @@ const workspaceSubscriptionSchema = z.object({
|
||||
status: z.string(),
|
||||
seats: z.number().nullable(),
|
||||
unlimitedSeats: z.boolean().nullable(),
|
||||
partnerTier: z.number().nullable(),
|
||||
periodStart: z.date().nullable(),
|
||||
periodEnd: z.date().nullable(),
|
||||
});
|
||||
@@ -76,7 +74,6 @@ export const workspaceCreateResponseSchema = z.object({
|
||||
slug: z.string(),
|
||||
description: z.string().nullable(),
|
||||
plan: z.enum(["free", "team", "pro", "enterprise"]),
|
||||
cardPrefix: z.string(),
|
||||
});
|
||||
|
||||
// ─── workspace.update ────────────────────────────────────────
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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,49 +10,13 @@ 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(),
|
||||
@@ -140,10 +104,7 @@ 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,
|
||||
@@ -165,9 +126,35 @@ export function createPlugins(db: dbClient) {
|
||||
subscription,
|
||||
cancellationDetails,
|
||||
);
|
||||
},
|
||||
onSubscriptionDeleted: async ({ subscription }) => {
|
||||
await cancelWorkspaceAccess(db, subscription.referenceId);
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSubscriptionUpdate: async ({ subscription }) => {
|
||||
await triggerWorkflow(db, "subscription-updated", subscription);
|
||||
@@ -196,10 +183,7 @@ 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 = "";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
DROP INDEX IF EXISTS "subscription_partner_license_key_idx";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -236,15 +236,14 @@
|
||||
{
|
||||
"idx": 33,
|
||||
"version": "7",
|
||||
"when": 1778617946519,
|
||||
"tag": "20260512203226_AddPartnerLicenseToSubscription",
|
||||
"when": 1778600989523,
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "7",
|
||||
"when": 1780057951781,
|
||||
"tag": "20260529123231_DropPartnerLicenseKeyUniqueConstraint",
|
||||
"when": 1778617946519,
|
||||
"tag": "20260512203226_AddPartnerLicenseToSubscription",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -5,27 +5,27 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./src/client.ts",
|
||||
"types": "./dist/client.d.ts",
|
||||
"default": "./src/client.ts"
|
||||
},
|
||||
"./schema": {
|
||||
"types": "./src/schema/index.ts",
|
||||
"types": "./dist/schema.d.ts",
|
||||
"default": "./src/schema/index.ts"
|
||||
},
|
||||
"./types/*": {
|
||||
"types": "./src/types/*",
|
||||
"types": "./dist/types/*.d.ts",
|
||||
"default": "./src/types/*"
|
||||
},
|
||||
"./repository/*": {
|
||||
"types": "./src/repository/*.ts",
|
||||
"types": "./dist/repository/*.d.ts",
|
||||
"default": "./src/repository/*.ts"
|
||||
},
|
||||
"./redis": {
|
||||
"types": "./src/redis.ts",
|
||||
"types": "./dist/redis.d.ts",
|
||||
"default": "./src/redis.ts"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -107,12 +107,7 @@ export const create = async (
|
||||
cardNumber,
|
||||
dueDate: cardInput.dueDate ?? null,
|
||||
})
|
||||
.returning({
|
||||
id: cards.id,
|
||||
listId: cards.listId,
|
||||
publicId: cards.publicId,
|
||||
cardNumber: cards.cardNumber,
|
||||
});
|
||||
.returning({ id: cards.id, listId: cards.listId, publicId: cards.publicId, cardNumber: cards.cardNumber });
|
||||
|
||||
if (!result[0]) throw new Error("Unable to create card");
|
||||
|
||||
@@ -326,7 +321,8 @@ export const bulkCreate = async (
|
||||
.where(eq(workspaces.id, workspaceId))
|
||||
.returning({ cardCounter: workspaces.cardCounter });
|
||||
|
||||
if (!counterResult) throw new Error(`Workspace ${workspaceId} not found`);
|
||||
if (!counterResult)
|
||||
throw new Error(`Workspace ${workspaceId} not found`);
|
||||
|
||||
const last = counterResult.cardCounter;
|
||||
const start = last - count + 1;
|
||||
@@ -490,7 +486,6 @@ export const getWithListAndMembersByPublicId = async (
|
||||
dueDate: true,
|
||||
createdBy: true,
|
||||
cardNumber: true,
|
||||
index: true,
|
||||
},
|
||||
with: {
|
||||
labels: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, count, eq, isNull, ne, or } from "drizzle-orm";
|
||||
import { and, count, eq, isNull } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import type { MemberRole, MemberStatus } from "@kan/db/schema";
|
||||
@@ -19,27 +19,6 @@ export const getActiveCount = async (db: dbClient) => {
|
||||
return result[0]?.count ?? 0;
|
||||
};
|
||||
|
||||
export const getCountByWorkspaceId = async (
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
) => {
|
||||
const result = await db
|
||||
.select({ count: count() })
|
||||
.from(workspaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMembers.workspaceId, workspaceId),
|
||||
isNull(workspaceMembers.deletedAt),
|
||||
or(
|
||||
eq(workspaceMembers.status, "active"),
|
||||
eq(workspaceMembers.status, "invited"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return result[0]?.count ?? 0;
|
||||
};
|
||||
|
||||
export const create = async (
|
||||
db: dbClient,
|
||||
memberInput: {
|
||||
@@ -92,14 +71,14 @@ export const getByPublicIdsWithUsers = async (
|
||||
return db.query.workspaceMembers.findMany({
|
||||
where: (members, { inArray: inArrayFn, eq, and, isNull: isNullFn }) => {
|
||||
const conditions = [inArrayFn(members.publicId, memberPublicIds)];
|
||||
|
||||
|
||||
if (workspaceId) {
|
||||
conditions.push(eq(members.workspaceId, workspaceId));
|
||||
}
|
||||
|
||||
|
||||
conditions.push(eq(members.status, "active"));
|
||||
conditions.push(isNullFn(members.deletedAt));
|
||||
|
||||
|
||||
return and(...conditions);
|
||||
},
|
||||
with: {
|
||||
@@ -193,68 +172,6 @@ export const pauseAllMembers = async (db: dbClient, workspaceId: number) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const getPreservableMemberId = async (
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
ownerUserId: string | null,
|
||||
): Promise<string | null> => {
|
||||
if (ownerUserId) {
|
||||
const owner = await db.query.workspaceMembers.findFirst({
|
||||
columns: { userId: true },
|
||||
where: and(
|
||||
eq(workspaceMembers.workspaceId, workspaceId),
|
||||
eq(workspaceMembers.userId, ownerUserId),
|
||||
eq(workspaceMembers.status, "active"),
|
||||
isNull(workspaceMembers.deletedAt),
|
||||
),
|
||||
});
|
||||
if (owner?.userId) return owner.userId;
|
||||
}
|
||||
|
||||
const admin = await db.query.workspaceMembers.findFirst({
|
||||
columns: { userId: true },
|
||||
where: and(
|
||||
eq(workspaceMembers.workspaceId, workspaceId),
|
||||
eq(workspaceMembers.role, "admin"),
|
||||
eq(workspaceMembers.status, "active"),
|
||||
isNull(workspaceMembers.deletedAt),
|
||||
),
|
||||
orderBy: (m, { asc }) => [asc(m.createdAt)],
|
||||
});
|
||||
if (admin?.userId) return admin.userId;
|
||||
|
||||
const anyMember = await db.query.workspaceMembers.findFirst({
|
||||
columns: { userId: true },
|
||||
where: and(
|
||||
eq(workspaceMembers.workspaceId, workspaceId),
|
||||
eq(workspaceMembers.status, "active"),
|
||||
isNull(workspaceMembers.deletedAt),
|
||||
),
|
||||
orderBy: (m, { asc }) => [asc(m.createdAt)],
|
||||
});
|
||||
return anyMember?.userId ?? null;
|
||||
};
|
||||
|
||||
export const pauseMembersExcept = async (
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
preserveUserId: string,
|
||||
) => {
|
||||
await db
|
||||
.update(workspaceMembers)
|
||||
.set({ status: "paused" })
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMembers.workspaceId, workspaceId),
|
||||
eq(workspaceMembers.status, "active"),
|
||||
or(
|
||||
isNull(workspaceMembers.userId),
|
||||
ne(workspaceMembers.userId, preserveUserId),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const updateRole = async (
|
||||
db: dbClient,
|
||||
args: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, eq, inArray, isNotNull, isNull } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { subscription } from "@kan/db/schema";
|
||||
@@ -14,10 +14,6 @@ export const updateById = async (
|
||||
periodStart?: Date | null;
|
||||
periodEnd?: Date | null;
|
||||
cancelAtPeriodEnd?: boolean | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
referenceId?: string | null;
|
||||
partnerLicenseKey?: string;
|
||||
partnerTier?: number;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
@@ -32,7 +28,6 @@ export const updateById = async (
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
unlimitedSeats: subscription.unlimitedSeats,
|
||||
referenceId: subscription.referenceId,
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -67,24 +62,6 @@ export const updateByStripeSubscriptionId = async (
|
||||
return result;
|
||||
};
|
||||
|
||||
export const updateAllByPartnerLicenseKey = async (
|
||||
db: dbClient,
|
||||
partnerLicenseKey: string,
|
||||
updates: {
|
||||
plan?: string;
|
||||
status?: string;
|
||||
partnerTier?: number;
|
||||
seats?: number | null;
|
||||
unlimitedSeats?: boolean;
|
||||
},
|
||||
) => {
|
||||
return await db
|
||||
.update(subscription)
|
||||
.set({ ...updates, updatedAt: new Date() })
|
||||
.where(eq(subscription.partnerLicenseKey, partnerLicenseKey))
|
||||
.returning({ id: subscription.id });
|
||||
};
|
||||
|
||||
export const getByStripeSubscriptionId = async (
|
||||
db: dbClient,
|
||||
stripeSubscriptionId: string,
|
||||
@@ -118,63 +95,13 @@ export const getByPartnerLicenseKey = async (
|
||||
db: dbClient,
|
||||
partnerLicenseKey: string,
|
||||
) => {
|
||||
return await db.query.subscription.findFirst({
|
||||
const result = await db.query.subscription.findFirst({
|
||||
where: eq(subscription.partnerLicenseKey, partnerLicenseKey),
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getAllByPartnerLicenseKey = async (
|
||||
db: dbClient,
|
||||
partnerLicenseKey: string,
|
||||
) => {
|
||||
return await db.query.subscription.findMany({
|
||||
where: eq(subscription.partnerLicenseKey, partnerLicenseKey),
|
||||
orderBy: [asc(subscription.id)],
|
||||
});
|
||||
};
|
||||
|
||||
export const getFirstUnlinkedSlotByLicenseKey = async (
|
||||
db: dbClient,
|
||||
partnerLicenseKey: string,
|
||||
) => {
|
||||
return await db.query.subscription.findFirst({
|
||||
where: and(
|
||||
eq(subscription.partnerLicenseKey, partnerLicenseKey),
|
||||
isNull(subscription.referenceId),
|
||||
inArray(subscription.status, ["active", "trialing"]),
|
||||
),
|
||||
orderBy: [asc(subscription.id)],
|
||||
});
|
||||
};
|
||||
|
||||
export const getAllActivePartnerSubsByWorkspaceIds = async (
|
||||
db: dbClient,
|
||||
workspacePublicIds: string[],
|
||||
) => {
|
||||
if (workspacePublicIds.length === 0) return [];
|
||||
return await db.query.subscription.findMany({
|
||||
where: and(
|
||||
inArray(subscription.referenceId, workspacePublicIds),
|
||||
isNotNull(subscription.partnerLicenseKey),
|
||||
inArray(subscription.status, ["active", "trialing"]),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const getFirstActivePartnerSubByWorkspaceIds = async (
|
||||
db: dbClient,
|
||||
workspacePublicIds: string[],
|
||||
) => {
|
||||
return await db.query.subscription.findFirst({
|
||||
where: and(
|
||||
inArray(subscription.referenceId, workspacePublicIds),
|
||||
isNotNull(subscription.partnerLicenseKey),
|
||||
inArray(subscription.status, ["active", "trialing"]),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const createPartnerLicenseSlots = async (
|
||||
export const upsertByPartnerLicenseKey = async (
|
||||
db: dbClient,
|
||||
partnerLicenseKey: string,
|
||||
data: {
|
||||
@@ -183,13 +110,27 @@ export const createPartnerLicenseSlots = async (
|
||||
partnerTier: number;
|
||||
seats: number | null;
|
||||
unlimitedSeats: boolean;
|
||||
referenceId?: string;
|
||||
},
|
||||
count: number,
|
||||
) => {
|
||||
const rows = Array.from({ length: count }, () => ({
|
||||
partnerLicenseKey,
|
||||
...data,
|
||||
referenceId: null,
|
||||
}));
|
||||
return await db.insert(subscription).values(rows).returning();
|
||||
const existing = await getByPartnerLicenseKey(db, partnerLicenseKey);
|
||||
|
||||
if (existing) {
|
||||
const [result] = await db
|
||||
.update(subscription)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(subscription.partnerLicenseKey, partnerLicenseKey))
|
||||
.returning();
|
||||
return result;
|
||||
}
|
||||
|
||||
const [result] = await db
|
||||
.insert(subscription)
|
||||
.values({
|
||||
partnerLicenseKey,
|
||||
...data,
|
||||
referenceId: data.referenceId ?? null,
|
||||
})
|
||||
.returning();
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -93,7 +93,6 @@ export const create = async (
|
||||
slug: workspaces.slug,
|
||||
description: workspaces.description,
|
||||
plan: workspaces.plan,
|
||||
cardPrefix: workspaces.cardPrefix,
|
||||
});
|
||||
|
||||
if (workspace) {
|
||||
@@ -176,7 +175,6 @@ export const getByPublicId = (db: dbClient, workspacePublicId: string) => {
|
||||
name: true,
|
||||
plan: true,
|
||||
slug: true,
|
||||
createdBy: true,
|
||||
},
|
||||
where: eq(workspaces.publicId, workspacePublicId),
|
||||
});
|
||||
@@ -240,7 +238,6 @@ export const getByPublicIdWithMembers = (
|
||||
status: true,
|
||||
seats: true,
|
||||
unlimitedSeats: true,
|
||||
partnerTier: true,
|
||||
periodStart: true,
|
||||
periodEnd: true,
|
||||
},
|
||||
@@ -315,16 +312,6 @@ export const getAllByUserId = async (db: dbClient, userId: string) => {
|
||||
return result.filter((member) => !member.workspace.deletedAt);
|
||||
};
|
||||
|
||||
export const getAllOwnedByUserId = async (db: dbClient, userId: string) => {
|
||||
return await db.query.workspaces.findMany({
|
||||
columns: {
|
||||
publicId: true,
|
||||
plan: true,
|
||||
},
|
||||
where: and(eq(workspaces.createdBy, userId), isNull(workspaces.deletedAt)),
|
||||
});
|
||||
};
|
||||
|
||||
export const getMemberByPublicId = (db: dbClient, memberPublicId: string) => {
|
||||
return db.query.workspaceMembers.findFirst({
|
||||
columns: {
|
||||
|
||||
@@ -5,33 +5,42 @@ import {
|
||||
integer,
|
||||
pgTable,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { workspaces } from "./workspaces";
|
||||
|
||||
export const subscription = pgTable("subscription", {
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
plan: varchar("plan", { length: 255 }).notNull(),
|
||||
referenceId: varchar("referenceId", { length: 12 }).references(
|
||||
() => workspaces.publicId,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
|
||||
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
|
||||
status: varchar("status", { length: 255 }).notNull(),
|
||||
periodStart: timestamp("periodStart"),
|
||||
periodEnd: timestamp("periodEnd"),
|
||||
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
|
||||
seats: integer("seats"),
|
||||
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
|
||||
trialStart: timestamp("trialStart"),
|
||||
trialEnd: timestamp("trialEnd"),
|
||||
partnerLicenseKey: varchar("partnerLicenseKey", { length: 255 }),
|
||||
partnerTier: integer("partnerTier"),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
}).enableRLS();
|
||||
export const subscription = pgTable(
|
||||
"subscription",
|
||||
{
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
plan: varchar("plan", { length: 255 }).notNull(),
|
||||
referenceId: varchar("referenceId", { length: 12 }).references(
|
||||
() => workspaces.publicId,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
|
||||
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
|
||||
status: varchar("status", { length: 255 }).notNull(),
|
||||
periodStart: timestamp("periodStart"),
|
||||
periodEnd: timestamp("periodEnd"),
|
||||
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
|
||||
seats: integer("seats"),
|
||||
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
|
||||
trialStart: timestamp("trialStart"),
|
||||
trialEnd: timestamp("trialEnd"),
|
||||
partnerLicenseKey: varchar("partnerLicenseKey", { length: 255 }),
|
||||
partnerTier: integer("partnerTier"),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("subscription_partner_license_key_idx").on(
|
||||
table.partnerLicenseKey,
|
||||
),
|
||||
],
|
||||
).enableRLS();
|
||||
|
||||
export const subscriptionsRelations = relations(subscription, ({ one }) => ({
|
||||
workspace: one(workspaces, {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./src/utils/index.ts",
|
||||
"types": "./dist/utils/index.d.ts",
|
||||
"default": "./src/utils/index.ts"
|
||||
},
|
||||
"./constants": {
|
||||
"types": "./src/constants/index.ts",
|
||||
"types": "./dist/constants/index.d.ts",
|
||||
"default": "./src/constants/index.ts"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,14 +16,8 @@ export function createS3Client() {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// The AWS SDK throws "Region is missing" if region is undefined or
|
||||
// empty string at S3Client construction. Default to "us-east-1" so
|
||||
// S3-compatible providers (MinIO, Backblaze B2, R2, Spaces, Wasabi)
|
||||
// that don't care about region work without forcing operators to
|
||||
// set a meaningless value. Real AWS S3 users should still set
|
||||
// S3_REGION explicitly to their bucket's actual region.
|
||||
return new S3Client({
|
||||
region: process.env.S3_REGION ?? "us-east-1",
|
||||
region: process.env.S3_REGION ?? "",
|
||||
endpoint: process.env.S3_ENDPOINT ?? "",
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
|
||||
credentials,
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface Subscription {
|
||||
status: string;
|
||||
seats: number | null;
|
||||
unlimitedSeats: boolean;
|
||||
partnerTier: number | null;
|
||||
periodStart: Date | null;
|
||||
periodEnd: Date | null;
|
||||
referenceId: string | null;
|
||||
@@ -54,14 +53,3 @@ export const hasUnlimitedSeats = (
|
||||
const activeSubscriptions = getActiveSubscriptions(subscriptions);
|
||||
return activeSubscriptions.some((sub) => sub.unlimitedSeats);
|
||||
};
|
||||
|
||||
export const getSeatLimit = (
|
||||
subscriptions: Subscription[] | undefined,
|
||||
): number | null => {
|
||||
const activeSubscriptions = getActiveSubscriptions(subscriptions);
|
||||
const partnerSub = activeSubscriptions.find(
|
||||
(sub) =>
|
||||
sub.partnerTier !== null && !sub.unlimitedSeats && sub.seats !== null,
|
||||
);
|
||||
return partnerSub?.seats ?? null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user