Compare commits

..

31 Commits

Author SHA1 Message Date
Henry
cb10b45e95 fix: encode attachment filename header to support non-ASCII characters 2026-06-09 12:11:16 +01:00
Henry
2893e95597 fix(cloud): free partner subscription slot when workspace is deleted 2026-05-31 21:21:36 +01:00
Henry
89654c20b0 fix(partner): scope auto-claim to owned workspaces and guard against active subs 2026-05-30 12:51:21 +01:00
hjball
4442e67832 chore: update translations 2026-05-29 22:53:24 +00:00
Henry
d7e8b5ae6b feat: reset checklist items to default when duplicating 2026-05-29 23:51:48 +01:00
Henry
03e1e19464 fix: ensure upgrade/downgrade paths for migrate requests 2026-05-29 23:48:01 +01:00
hjball
a3353acf44 chore: compile translations 2026-05-29 22:29:11 +00:00
hjball
a4a807ed1d chore: update translations 2026-05-29 22:29:06 +00:00
Henry
6b8ada8d90 feat: duplicate card via dropdown 2026-05-29 23:27:21 +01:00
hjball
22899cd14c chore: update translations 2026-05-29 21:59:33 +00:00
Henry
833c69a73e cloud: enable workspace slots for partners 2026-05-29 22:58:09 +01:00
hjball
d9cf363302 chore: update translations 2026-05-25 21:01:17 +00:00
Henry
9212b1ca2e fix: adjust layout of card prefix on public modal 2026-05-25 21:59:57 +01:00
hjball
ede2475796 chore: update translations 2026-05-25 20:49:46 +00:00
Henry
e32ad1cd32 fix(cloud): hide empty notice box for pro subscriptions 2026-05-25 21:48:27 +01:00
hjball
bac22021ad chore: update translations 2026-05-21 21:53:34 +00:00
Henry
8d8cfa3bae feat(cloud): pause members when subscription is cancelled 2026-05-21 22:52:17 +01:00
hjball
971348a397 chore: compile translations 2026-05-21 20:39:43 +00:00
hjball
d3920cd246 chore: update translations 2026-05-21 20:39:39 +00:00
Henry
5af375825b fix: reliably select workspace on login (#507) 2026-05-21 21:38:09 +01:00
Henry
861416a18e feat(cloud): set seat limit checks for partner member invitations (#506) 2026-05-21 21:36:57 +01:00
Henry
a7e78ad580 fix: respect redirect param on login and signup pages (#504) 2026-05-21 21:36:02 +01:00
Henry
f288047a6a fix: run web container as non-root user (UID 1000) (#501) 2026-05-21 21:35:47 +01:00
hjball
edb88525c0 chore: compile translations 2026-05-18 22:21:55 +00:00
hjball
d69051611c chore: update translations 2026-05-18 22:21:52 +00:00
Henry
cf566e04c6 feat(cloud): improved upgrade journey 2026-05-18 23:20:11 +01:00
Henry
6ea622e7f7 fix(cloud): use prev_licence_key for upgrade/downgrade handler 2026-05-16 23:38:04 +01:00
Danny Halarewich
0e7b7a072c fix(editor): convert pasted markdown to formatted content (#498)
The tiptap-markdown extension was registered with default config, where
`transformPastedText` defaults to false. As a result, pasting markdown
text into card descriptions resulted in literal characters (e.g. '# heading'
shown as a paragraph of text) rather than rendered formatting.

Typing markdown shortcuts in the editor already worked because that goes
through a different path. Programmatic writes via the API also work
because they bypass the editor entirely. Only paste was affected.

Enabling `transformPastedText: true` makes pasted markdown behave as
users intuitively expect, matching the typed-markdown UX.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 23:03:06 +01:00
Nick Meinhold
f801d2a7eb fix(s3): default S3_REGION to us-east-1 instead of empty string (#495)
The AWS SDK throws "Region is missing" when S3Client is constructed
with an empty-string region, so any S3 feature (avatars, attachments,
presigned URL generation) is silently broken when S3_REGION is unset
— even though .env.example ships it unset and env.ts declares it
optional. The schema and the runtime disagreed about whether the var
was required.

Default to "us-east-1" in createS3Client. S3-compatible providers
(MinIO, Backblaze B2, R2, DigitalOcean Spaces, Wasabi) ignore the
region entirely; real AWS S3 users should set S3_REGION explicitly
to their bucket's actual region (clarified in .env.example).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:56:48 +01:00
Nick Meinhold
d9157b6218 fix(workspace): point package "types" exports at src/, not stale dist/ (#496)
Each workspace package had its exports map pointing `types` at
`./dist/...d.ts` while `default` pointed at `./src/...ts`. The dist
directory is a build artifact only refreshed when someone runs
`pnpm build` in that package — meaning every other package in the
monorepo typechecks against last week's type signatures.

Concrete failure mode: contributor edits a function in
packages/shared/src/utils/foo.ts (changes a parameter type, adds a
new export, etc.), runs `pnpm typecheck` from root, sees green. The
check actually validated against the stale dist/.d.ts. Real type
errors stay invisible until CI builds shared first, by which point
the diff is already pushed.

Fix: point `types` at the same `src/` paths the runtime resolves to.
TypeScript reads .ts source as types fine when consumers share the
same TS version, which a monorepo guarantees. Verified end-to-end:
adding a new export to shared and immediately typechecking @kan/api
now picks it up without rebuilding shared, and breaking a return
type immediately fails the consumer's typecheck.

Applied to @kan/api, @kan/db, @kan/logger, @kan/shared.
@kan/email is intentionally left as-is because its source is .tsx
(JSX) — pointing types at .tsx would force every consumer to enable
--jsx in their tsconfig, which is a worse cascade than the stale
dist problem we're solving.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:55:07 +01:00
hjball
711ff7f610 chore: update translations 2026-05-16 21:36:10 +00:00
69 changed files with 8075 additions and 2065 deletions

View File

@@ -23,8 +23,8 @@ SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
NEXT_PUBLIC_DISABLE_EMAIL=
# S3 storage (optional)
S3_REGION=
S3_ENDPOINT=
S3_REGION= # required for AWS S3 (e.g. us-east-1); ignored by most S3-compatible providers — defaults to us-east-1 if unset
S3_ENDPOINT= # set for S3-compatible providers (MinIO, R2, Spaces, etc.); leave blank for AWS S3
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=

View File

@@ -555,7 +555,11 @@ checksums:
kryGs%2B/translation: bba0beaced7ea954ceb980f2b022ffee
sU2uWc/message: 3fe52244676faf64e5266b6713d01e07
sU2uWc/origin/0/0: 97bd206a05414e2a571020d64e166093
sU2uWc/origin/1/0: 1b05beba6bdd908015fa8e798717f024
sU2uWc/translation: 3fe52244676faf64e5266b6713d01e07
9trBXW/message: 0035c67fb6f70992effb263b0b30e6d4
9trBXW/origin/0/0: 1b05beba6bdd908015fa8e798717f024
9trBXW/translation: 0035c67fb6f70992effb263b0b30e6d4
fEY2vP/message: 91509e2f92b0b3b11330b6983139fdbf
fEY2vP/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
fEY2vP/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
@@ -603,6 +607,10 @@ 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
@@ -1005,6 +1013,7 @@ checksums:
BTDPLo/message: f6a33afcf5ff1489551c94867e083dca
BTDPLo/origin/0/0: 97bd206a05414e2a571020d64e166093
BTDPLo/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
BTDPLo/origin/2/0: 1b05beba6bdd908015fa8e798717f024
BTDPLo/translation: f6a33afcf5ff1489551c94867e083dca
06EQqT/message: e9f56489fb395a890ae2557200c29a36
06EQqT/origin/0/0: 97bd206a05414e2a571020d64e166093
@@ -1146,8 +1155,7 @@ checksums:
x0kMO%2F/origin/0/0: 30791e26a172d47d3cb0693e82679246
x0kMO%2F/translation: 9da6c012e1b512d8c7fbd812626c4675
YaZDYI/message: 57cc47581c7faaffb3b1721b6352b576
YaZDYI/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
YaZDYI/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
YaZDYI/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
YaZDYI/translation: 57cc47581c7faaffb3b1721b6352b576
lsgnzd/message: 934c6867d9bdbe6099b2c0f5a3107f78
lsgnzd/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
@@ -1256,6 +1264,9 @@ checksums:
2POOFK/origin/4/0: 9737a9cef0646ff4bbd942b1924b9a2c
2POOFK/origin/5/0: fadd5a5b6963ec5f261072f7e8d2140b
2POOFK/translation: 0326365539c004f6088656f692602078
8LLt89/message: 9a78178dc1874543d35153ae325e2811
8LLt89/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
8LLt89/translation: 9a78178dc1874543d35153ae325e2811
uP4V6I/message: 2d403501f0488ec7d4eac979faf051a2
uP4V6I/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
uP4V6I/translation: 2d403501f0488ec7d4eac979faf051a2
@@ -1479,6 +1490,9 @@ checksums:
"%2BdjOzj/origin/0/0": faa852a0c0e3c81fe948e394029c1ba5
"%2BdjOzj/origin/1/0": 8628ddb6f4ded25c0f17e2cbda8207d3
"%2BdjOzj/translation": ade922db1be6b26bc979565ce5de2bc7
M2zwdn/message: 4c9c0d43434ad63a70e8f956d1900602
M2zwdn/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
M2zwdn/translation: 4c9c0d43434ad63a70e8f956d1900602
qPP4lD/message: 03eeed2d715e770259f722ba48a61ab3
qPP4lD/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
qPP4lD/translation: 03eeed2d715e770259f722ba48a61ab3
@@ -1624,11 +1638,17 @@ 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
@@ -1900,6 +1920,9 @@ 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
@@ -1970,19 +1993,18 @@ checksums:
tyHiOa/origin/34/0: afee530613110e248ffde6b56872ebed
tyHiOa/origin/35/0: afee530613110e248ffde6b56872ebed
tyHiOa/origin/36/0: 8628ddb6f4ded25c0f17e2cbda8207d3
tyHiOa/origin/37/0: 8628ddb6f4ded25c0f17e2cbda8207d3
tyHiOa/origin/38/0: 7493592314bc3b96ad96127a949ba1ba
tyHiOa/origin/37/0: 7493592314bc3b96ad96127a949ba1ba
tyHiOa/origin/38/0: 69d8634b177fdf937764b406fd477547
tyHiOa/origin/39/0: 69d8634b177fdf937764b406fd477547
tyHiOa/origin/40/0: 69d8634b177fdf937764b406fd477547
tyHiOa/origin/41/0: 03c00909b41be01231cd3e843c8ee44e
tyHiOa/origin/42/0: 5c60161e173f5eae9276d8e4c70b4ebb
tyHiOa/origin/43/0: 83911e3eacbad4583e2b1647a784d154
tyHiOa/origin/44/0: d465da67c0a870d198eb7f313569c137
tyHiOa/origin/45/0: 33bf266228f835e12c0bfb0e5efb3be9
tyHiOa/origin/46/0: ff48991e314b0b3e989a4dac59860fd5
tyHiOa/origin/47/0: 30791e26a172d47d3cb0693e82679246
tyHiOa/origin/48/0: dbad0c8d7863cb41f5495264f82e7081
tyHiOa/origin/49/0: 0dccc39d0debbf3e089cb6d292de762b
tyHiOa/origin/40/0: 03c00909b41be01231cd3e843c8ee44e
tyHiOa/origin/41/0: 5c60161e173f5eae9276d8e4c70b4ebb
tyHiOa/origin/42/0: 83911e3eacbad4583e2b1647a784d154
tyHiOa/origin/43/0: d465da67c0a870d198eb7f313569c137
tyHiOa/origin/44/0: 33bf266228f835e12c0bfb0e5efb3be9
tyHiOa/origin/45/0: ff48991e314b0b3e989a4dac59860fd5
tyHiOa/origin/46/0: 30791e26a172d47d3cb0693e82679246
tyHiOa/origin/47/0: dbad0c8d7863cb41f5495264f82e7081
tyHiOa/origin/48/0: 0dccc39d0debbf3e089cb6d292de762b
tyHiOa/translation: 21ffcf0b00e7cd7b64f7454a95762e1d
PZCqeW/message: 325dea6dd0348a27a6818db2c1340c98
PZCqeW/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
@@ -1994,8 +2016,9 @@ checksums:
fuwKpE/origin/2/0: fc0c18a095a68277a0f90a115942683b
fuwKpE/origin/3/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/4/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/5/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/6/0: faa8a2576972b4eb31565d8ea6098f43
fuwKpE/origin/5/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/6/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/7/0: faa8a2576972b4eb31565d8ea6098f43
fuwKpE/translation: 346a18a54564881c888b95873d03df44
nYNMZZ/message: d5a8a1b38eaf02d02cd6ac2211b42a92
nYNMZZ/origin/0/0: 97bd206a05414e2a571020d64e166093
@@ -2026,7 +2049,8 @@ checksums:
3fPjUY/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
3fPjUY/translation: 682b3c9feab30112b4454cb5bb7974b1
oyRjD%2F/message: 76a09fd843b496610f8cf1dced417b62
oyRjD%2F/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
oyRjD%2F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
oyRjD%2F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
oyRjD%2F/translation: 76a09fd843b496610f8cf1dced417b62
Qtzfdk/message: a898be9e4610706b9b8146a209e8ba16
Qtzfdk/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
@@ -2170,6 +2194,13 @@ checksums:
SkCNhl/message: 137017b2b3e885c4894ca8c899af5bc2
SkCNhl/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
SkCNhl/translation: 137017b2b3e885c4894ca8c899af5bc2
66Jmqh/message: 5f56ef5447d5d3ae81d26936ca8e6758
66Jmqh/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
66Jmqh/translation: 5f56ef5447d5d3ae81d26936ca8e6758
MpFIca/message: 6e3f470ef5468c5195ae09f518b4dea4
MpFIca/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
MpFIca/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
MpFIca/translation: 6e3f470ef5468c5195ae09f518b4dea4
e1v%2BJ3/message: 9aeca4f286e14966ef1c32d2abc74584
e1v%2BJ3/origin/0/0: f64cd62b620f454998523af2a4330278
e1v%2BJ3/translation: 9aeca4f286e14966ef1c32d2abc74584
@@ -2267,6 +2298,10 @@ 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
@@ -2325,9 +2360,13 @@ checksums:
R6pB8R/origin/0/0: 09a6a32f9a6b415e81b4d2f8181a0e6b
R6pB8R/translation: e5cb0428f1ac6485688f120d78742875
veIDCY/message: 4be2811a8f3455618032ccb59e40658b
veIDCY/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
veIDCY/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
veIDCY/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
veIDCY/translation: 4be2811a8f3455618032ccb59e40658b
4%2BJaWo/message: e346e4ed7d138dcc873db187922369da
4%2BJaWo/origin/0/0: 94d3e20c29bb517dd394711d7524e478
4%2BJaWo/origin/1/0: 94d3e20c29bb517dd394711d7524e478
4%2BJaWo/origin/2/0: 94d3e20c29bb517dd394711d7524e478
4%2BJaWo/translation: e346e4ed7d138dcc873db187922369da
Y8yzGg/message: 396a94648584067cc5795f7b32d3d663
Y8yzGg/origin/0/0: 82a5cf8d69f098641eb357bf102cd504
Y8yzGg/translation: 396a94648584067cc5795f7b32d3d663
@@ -2362,7 +2401,8 @@ checksums:
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
bff61F/message: 3f0ae9c914f16ceb9a0d7db868f4aa20
bff61F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
bff61F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
bff61F/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
bff61F/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
bff61F/translation: 3f0ae9c914f16ceb9a0d7db868f4aa20
CAL6E9/message: b63448c05270497973ac4407047dae02
CAL6E9/origin/0/0: c0b59968b2b234167c488a05cc38710b
@@ -2491,6 +2531,9 @@ checksums:
mwBdZ2/message: b49224632bd6c3b7f5e462912aeb1081
mwBdZ2/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
mwBdZ2/translation: b49224632bd6c3b7f5e462912aeb1081
PV7XRK/message: 408b8862ad5ec9027eeec59b129ad18f
PV7XRK/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
PV7XRK/translation: 408b8862ad5ec9027eeec59b129ad18f
duf3Nr/message: b455329e2a71da677acab91d3a00bad6
duf3Nr/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
duf3Nr/translation: b455329e2a71da677acab91d3a00bad6
@@ -2599,6 +2642,7 @@ 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
@@ -2710,6 +2754,9 @@ 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
@@ -2719,6 +2766,9 @@ 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
@@ -2752,13 +2802,12 @@ checksums:
zERArS/placeholders/0/0: 06f23520ebabfffed4999b5bcf61c9cd
zERArS/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
zERArS/translation: aca196d729401d8adc5c8ca9d558840e
kwkhPe/translation: 63c3b52882e0d779859307d672c178c2
kwkhPe/message: 63c3b52882e0d779859307d672c178c2
kwkhPe/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
kwkhPe/origin/1/0: 15ea2dfd5eae4e05b226b1320103476e
kwkhPe/translation: 63c3b52882e0d779859307d672c178c2
IelE1h/message: 972773025e763ddf53273df6d37a729a
IelE1h/origin/0/0: 94d3e20c29bb517dd394711d7524e478
IelE1h/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
IelE1h/origin/2/0: 5f6c4a0c9be5098099167871ae1564af
IelE1h/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
IelE1h/translation: 972773025e763ddf53273df6d37a729a
cWtfn1/message: 9a91ea70f5eb55aac96640bf0fd7feff
cWtfn1/origin/0/0: 69d8634b177fdf937764b406fd477547
@@ -3015,6 +3064,10 @@ checksums:
uOqaMC/message: 24fc6cdc8740f37a83df85f582f03293
uOqaMC/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
uOqaMC/translation: 24fc6cdc8740f37a83df85f582f03293
QriiAI/message: 37a6b38e70fdfef1e2208ede67aeab98
QriiAI/placeholders/0/0: 4ab04dd216aebb6485be0c4b94d5ca6b
QriiAI/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
QriiAI/translation: 37a6b38e70fdfef1e2208ede67aeab98
8Zjc7q/message: 65609a209e74f230563a7c8d7258a83c
8Zjc7q/origin/0/0: 278a40eb45b1219f424703d0abc1d273
8Zjc7q/translation: 65609a209e74f230563a7c8d7258a83c

View File

@@ -474,7 +474,7 @@ export default function Editor({
autolink: true,
linkOnPaste: true,
}),
Markdown,
Markdown.configure({ transformPastedText: true }),
Placeholder.configure({
placeholder: readOnly
? ""

View File

@@ -28,7 +28,6 @@ import ButtonComponent from "~/components/Button";
import ReactiveButton from "~/components/ReactiveButton";
import UserMenu from "~/components/UserMenu";
import WorkspaceMenu from "~/components/WorkspaceMenu";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
@@ -53,7 +52,6 @@ export default function SideNavigation({
const { workspace } = useWorkspace();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isInitialised, setIsInitialised] = useState(false);
const { openModal } = useModal();
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
@@ -223,21 +221,19 @@ export default function SideNavigation({
<ButtonComponent
iconLeft={<HiBolt />}
variant="secondary"
href="/settings/workspace?upgrade=pro"
aria-label="Upgrade to Pro"
title="Upgrade to Pro"
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
aria-label={t`Start free trial`}
title={t`Start free trial`}
iconOnly
onClick={() => openModal("UPGRADE_TO_PRO")}
/>
) : (
<ButtonComponent
iconLeft={<HiBolt />}
fullWidth
variant="secondary"
href="/settings/workspace?upgrade=pro"
onClick={() => openModal("UPGRADE_TO_PRO")}
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
>
{t`Upgrade to Pro`}
{t`Start free trial`}
</ButtonComponent>
)}
</div>

View File

@@ -1,6 +1,6 @@
import { useRouter } from "next/navigation";
import { Button, Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { Fragment, useState } from "react";
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
@@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import CommandPallette from "./CommandPallette";
import { Tooltip } from "./Tooltip";
@@ -20,6 +21,8 @@ export default function WorkspaceMenu({
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace();
const { openModal } = useModal();
const { data: hasPartnerSlot } =
api.workspace.hasAvailablePartnerSlot.useQuery();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
@@ -150,11 +153,19 @@ export default function WorkspaceMenu({
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
<Menu.Item>
<button
onClick={() =>
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`)
: openModal("NEW_WORKSPACE")
}
onClick={() => {
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
openModal("NEW_WORKSPACE");
} else if (hasPartnerSlot) {
router.push(
`/onboarding/workspace?partner=1&returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
} else {
router.push(
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
}
}}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
{t`Create workspace`}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -4,12 +4,13 @@ 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 },
2: { plan: "pro", seats: 15, unlimitedSeats: false },
3: { plan: "pro", seats: null, unlimitedSeats: true },
1: { plan: "team", seats: 5, unlimitedSeats: false, workspaceSlots: 1 },
2: { plan: "pro", seats: 15, unlimitedSeats: false, workspaceSlots: 2 },
3: { plan: "pro", seats: null, unlimitedSeats: true, workspaceSlots: 4 },
};
export function tierConfig(tier: number): TierConfig {

View File

@@ -4,7 +4,6 @@ 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";
@@ -110,11 +109,15 @@ export default withRateLimit(
const { db, user } = await createNextApiContext(req);
const cfg = tierConfig(license.tier);
const isActive = license.status === "active";
const status = isActive ? "active" : "inactive";
const status = license.status === "active" ? "active" : "inactive";
if (!user) {
await subscriptionRepo.upsertByPartnerLicenseKey(
// Ensure subscription slots exist — webhook may have already created them
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license.license_key,
);
if (existing.length === 0) {
await subscriptionRepo.createPartnerLicenseSlots(
db,
license.license_key,
{
@@ -124,45 +127,18 @@ export default withRateLimit(
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
cfg.workspaceSlots,
);
}
if (!user) {
return res.redirect(
`/partner/activate?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/workspace?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`);
return res.redirect(
`/api/partner/link?license_key=${encodeURIComponent(license.license_key)}`,
);
}),
);

View File

@@ -27,38 +27,72 @@ export default withRateLimit(
);
}
const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key);
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license_key,
);
if (!sub) {
if (!allSlots.length) {
return res.redirect("/boards?partner_error=invalid_license");
}
if (sub.status !== "active") {
const activeSlots = allSlots.filter((s) =>
["active", "trialing"].includes(s.status),
);
if (!activeSlots.length) {
return res.redirect("/boards?partner_error=license_inactive");
}
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
const workspace = memberships?.[0]?.workspace;
const unlinkedSlot = activeSlots.find((s) => !s.referenceId);
if (!workspace) {
if (!unlinkedSlot) {
return res.redirect("/boards?partner_activated=1");
}
const linkedIds = new Set(
activeSlots.filter((s) => s.referenceId).map((s) => s.referenceId!),
);
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
const availableWorkspace = memberships
.map((m) => m.workspace)
.find((w) => w && !w.deletedAt && !linkedIds.has(w.publicId));
if (!availableWorkspace) {
return res.redirect(
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
);
}
await subscriptionRepo.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 subscriptionRepo.updateById(db, unlinkedSlot.id, {
referenceId: availableWorkspace.publicId,
});
await workspaceRepo.update(db, workspace.publicId, {
plan: sub.plan as "free" | "team" | "pro" | "enterprise",
await workspaceRepo.update(db, availableWorkspace.publicId, {
plan: unlinkedSlot.plan as "free" | "team" | "pro" | "enterprise",
});
const remainingUnlinked = activeSlots.filter(
(s) => !s.referenceId && s.id !== unlinkedSlot.id,
);
if (remainingUnlinked.length > 0) {
const updatedLinkedIds = new Set([
...linkedIds,
availableWorkspace.publicId,
]);
const hasMoreAvailableWorkspace = memberships
.map((m) => m.workspace)
.some((w) => w && !w.deletedAt && !updatedLinkedIds.has(w.publicId));
if (hasMoreAvailableWorkspace) {
return res.redirect(
`/api/partner/link?license_key=${encodeURIComponent(license_key)}`,
);
}
}
return res.redirect("/boards?partner_activated=1");
}),
);

View File

@@ -2,16 +2,112 @@ 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) {
@@ -41,6 +137,12 @@ function verifySignature(
}
}
function hasReferenceId<T extends { referenceId: string | null | undefined }>(
s: T,
): s is T & { referenceId: string } {
return !!s.referenceId;
}
interface WebhookPayload {
event:
| "purchase"
@@ -92,90 +194,212 @@ export default withApiLogging(
const { db } = await createNextApiContext(req);
switch (event) {
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 "purchase":
case "activate": {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license_key,
);
const status = event === "activate" ? "active" : license_status;
if (existing.length === 0) {
await subscriptionRepo.createPartnerLicenseSlots(
db,
license_key,
{
plan: cfg.plan,
status,
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
cfg.workspaceSlots,
);
} else {
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status,
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
}
break;
}
case "deactivate": {
const sub = await subscriptionRepo.getByPartnerLicenseKey(
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license_key,
);
if (sub) {
await subscriptionRepo.updateById(db, sub.id, {
plan: "free",
status: "inactive",
});
if (sub.referenceId) {
await workspaceRepo.update(db, sub.referenceId, { plan: "free" });
}
}
await Promise.all(
allSlots.filter(hasReferenceId).map(async (slot) => {
const siblingSubs = await subscriptionRepo.getByReferenceId(
db,
slot.referenceId,
);
const hasOtherActiveSub = getActiveSubscriptions(siblingSubs).some(
(s) => s.id !== slot.id,
);
if (!hasOtherActiveSub) {
await cancelWorkspaceAccess(db, slot.referenceId);
}
}),
);
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
plan: "free",
status: "canceled",
unlimitedSeats: false,
seats: null,
});
break;
}
case "upgrade":
case "downgrade": {
const sub = await subscriptionRepo.getByPartnerLicenseKey(
const lookupKey = prev_license_key ?? license_key;
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
license_key,
lookupKey,
);
if (sub) {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
if (sub.referenceId) {
await workspaceRepo.update(db, sub.referenceId, { plan: cfg.plan });
}
if (existing.length === 0) break;
const cfg = tierConfig(tier);
const newCount = cfg.workspaceSlots;
// Prefer keeping linked slots; among linked, keep in insertion order (LIFO removal)
const preferKeep = [
...existing.filter((s) => s.referenceId),
...existing.filter((s) => !s.referenceId),
];
const slotsToKeep = preferKeep.slice(0, newCount);
const slotsToRemove = preferKeep.slice(newCount);
await Promise.all([
...slotsToKeep.map((slot) =>
subscriptionRepo.updateById(db, slot.id, {
plan: cfg.plan,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
partnerTier: tier,
status: "active",
...(prev_license_key ? { partnerLicenseKey: license_key } : {}),
}),
),
...slotsToKeep
.filter(hasReferenceId)
.map((s) =>
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
),
]);
if (newCount > existing.length) {
const linkedIds = new Set(
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
);
await createAndLinkSlots(
db,
license_key,
cfg,
tier,
linkedIds,
newCount - existing.length,
);
}
if (slotsToRemove.length > 0) {
await Promise.all([
...slotsToRemove
.filter(hasReferenceId)
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
...slotsToRemove.map((s) =>
subscriptionRepo.updateById(db, s.id, {
plan: "free",
status: "inactive",
unlimitedSeats: false,
seats: null,
}),
),
]);
}
break;
}
case "migrate": {
if (prev_license_key) {
const sub = await subscriptionRepo.getByPartnerLicenseKey(
db,
prev_license_key,
);
if (sub) {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
if (!prev_license_key) break;
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
db,
prev_license_key,
);
if (existing.length === 0) break;
const cfg = tierConfig(tier);
const newCount = cfg.workspaceSlots;
const preferKeep = [
...existing.filter((s) => s.referenceId),
...existing.filter((s) => !s.referenceId),
];
const slotsToKeep = preferKeep.slice(0, newCount);
const slotsToRemove = preferKeep.slice(newCount);
await Promise.all([
...slotsToKeep.map((slot) =>
subscriptionRepo.updateById(db, slot.id, {
partnerLicenseKey: license_key,
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
referenceId: sub.referenceId ?? undefined,
});
await subscriptionRepo.updateById(db, sub.id, {
status: "inactive",
});
}
}),
),
...slotsToKeep
.filter(hasReferenceId)
.map((s) =>
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
),
]);
if (newCount > existing.length) {
const linkedIds = new Set(
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
);
await createAndLinkSlots(
db,
license_key,
cfg,
tier,
linkedIds,
newCount - existing.length,
);
}
if (slotsToRemove.length > 0) {
await Promise.all([
...slotsToRemove
.filter(hasReferenceId)
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
...slotsToRemove.map((s) =>
subscriptionRepo.updateById(db, s.id, {
plan: "free",
status: "inactive",
unlimitedSeats: false,
seats: null,
}),
),
]);
}
break;
}

View File

@@ -64,7 +64,10 @@ export default async function handler(
meta.userId &&
meta.userEmail
) {
const existing = await workspaceRepo.getByPublicId(db, meta.workspacePublicId);
const existing = await workspaceRepo.getByPublicId(
db,
meta.workspacePublicId,
);
if (!existing) {
const slug = meta.workspaceSlug ?? meta.workspacePublicId;
@@ -90,10 +93,10 @@ export default async function handler(
});
}
} else {
// Existing workspace upgrade — update plan (and slug for pro)
await workspaceRepo.update(db, meta.workspacePublicId, {
plan,
...(plan === "pro" && meta.workspaceSlug && { slug: meta.workspaceSlug }),
...(plan === "pro" &&
meta.workspaceSlug && { slug: meta.workspaceSlug }),
});
}

View File

@@ -67,8 +67,15 @@ export default withRateLimit(
return res.status(400).json({ error: "File too large" });
}
const originalFilenameHeader =
const rawFilenameHeader =
(req.headers["x-original-filename"] as string | undefined) ?? "file";
const originalFilenameHeader = (() => {
try {
return decodeURIComponent(rawFilenameHeader);
} catch {
return rawFilenameHeader;
}
})();
const sanitizedFilename = originalFilenameHeader
.replace(/[^a-zA-Z0-9._-]/g, "_")

View File

@@ -64,8 +64,15 @@ export default withRateLimit(
return res.status(400).json({ error: "File too large" });
}
const originalFilenameHeader =
const rawFilenameHeader =
(req.headers["x-original-filename"] as string | undefined) ?? "file";
const originalFilenameHeader = (() => {
try {
return decodeURIComponent(rawFilenameHeader);
} catch {
return rawFilenameHeader;
}
})();
const sanitizedFilename = originalFilenameHeader
.replace(/[^a-zA-Z0-9._-]/g, "_")

View File

@@ -2,6 +2,7 @@ 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";
@@ -16,6 +17,8 @@ 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("");
@@ -55,6 +58,11 @@ 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`
)}

View File

@@ -0,0 +1,31 @@
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { authClient } from "@kan/auth/client";
import { PageHead } from "~/components/PageHead";
import SelectPlanView from "~/views/onboarding/select-plan";
export default function UpgradeSelectPlanPage() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
useEffect(() => {
if (!isPending && !session?.user) {
router.push("/login");
}
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
router.push("/boards");
}
}, [session, isPending, router]);
if (isPending || !session?.user) return null;
return (
<>
<PageHead title="Upgrade | kan.bn" />
<SelectPlanView />
</>
);
}

View File

@@ -103,55 +103,72 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
}
if (storedWorkspaceId !== null) {
const newData = data;
const selectedWorkspace = newData.find(
const selectedWorkspace = data.find(
({ workspace }) => workspace.publicId === storedWorkspaceId,
);
if (!selectedWorkspace?.workspace) {
pollAttemptsRef.current += 1;
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
setPendingWorkspaceId(null);
if (pendingWorkspaceId) {
pollAttemptsRef.current += 1;
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
setPendingWorkspaceId(null);
localStorage.removeItem("workspacePublicId");
} else {
return;
}
} else {
// Clear stale workspacePublicId from localStorage
localStorage.removeItem("workspacePublicId");
}
} else {
pollAttemptsRef.current = 0;
setPendingWorkspaceId(null);
setWorkspace({
publicId: selectedWorkspace.workspace.publicId,
name: selectedWorkspace.workspace.name,
slug: selectedWorkspace.workspace.slug,
plan: selectedWorkspace.workspace.plan,
description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role as "admin" | "member" | "guest",
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
cardPrefix: selectedWorkspace.workspace.cardPrefix,
});
if (workspacePublicId) {
router.push(`/boards`);
localStorage.setItem("workspacePublicId", workspacePublicId);
}
setHasLoaded(true);
return;
}
pollAttemptsRef.current = 0;
setPendingWorkspaceId(null);
setWorkspace({
publicId: selectedWorkspace.workspace.publicId,
name: selectedWorkspace.workspace.name,
slug: selectedWorkspace.workspace.slug,
plan: selectedWorkspace.workspace.plan,
description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role,
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
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,
});
}
}, [data, isLoading, isFetching, workspacePublicId, router]);
const primaryWorkspace = data[0]?.workspace;
const primaryWorkspaceRole = data[0]?.role;
if (!primaryWorkspace || !primaryWorkspaceRole) return;
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
setWorkspace({
publicId: primaryWorkspace.publicId,
name: primaryWorkspace.name,
slug: primaryWorkspace.slug,
plan: primaryWorkspace.plan,
description: primaryWorkspace.description,
role: primaryWorkspaceRole as "admin" | "member" | "guest",
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
cardPrefix: primaryWorkspace.cardPrefix,
});
setHasLoaded(true);
}, [
data,
isLoading,
isFetching,
workspacePublicId,
pendingWorkspaceId,
router,
]);
return (
<WorkspaceContext.Provider

View File

@@ -26,7 +26,7 @@ export default function LoginPage() {
const { data } = authClient.useSession();
if (data?.user.id) router.push("/boards");
if (data?.user.id) router.push(redirect ?? "/boards");
return (
<>

View File

@@ -21,7 +21,7 @@ export default function SignUpPage() {
const { data } = authClient.useSession();
if (data?.user.id) router.push("/boards");
if (data?.user.id) router.push(redirect ?? "/boards");
const handleMagicLinkSent = (value: boolean, recipient: string) => {
setIsMagicLinkSent(value);

View File

@@ -1,4 +1,5 @@
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useRef, useState } from "react";
import { HiOutlinePaperClip } from "react-icons/hi";
import { HiCheckBadge } from "react-icons/hi2";
@@ -7,7 +8,6 @@ import { twMerge } from "tailwind-merge";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { env } from "next-runtime-env";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
@@ -30,7 +30,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
method: "POST",
headers: {
"Content-Type": file.type,
"x-original-filename": file.name,
"x-original-filename": encodeURIComponent(file.name),
},
body: file,
},

View File

@@ -4,6 +4,7 @@ import {
HiHashtag,
HiLink,
HiOutlineCheckCircle,
HiOutlineDocumentDuplicate,
HiOutlineTrash,
} from "react-icons/hi2";
@@ -13,6 +14,7 @@ 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,
@@ -20,19 +22,44 @@ 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
@@ -99,6 +126,24 @@ export default function CardDropdown({
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
),
},
{
label: t`Duplicate card`,
action: () => {
if (!listPublicId || cardIndex === undefined) return;
duplicateCard.mutate({
cardPublicId,
listPublicId,
index: cardIndex + 1,
copyLabels: true,
copyMembers: true,
copyChecklists: true,
});
},
icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
),
disabled: duplicateCard.isPending || !listPublicId,
},
]
: []),
...(canDeleteCard || isCreator

View File

@@ -185,7 +185,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0]
: router.query.cardId;
const { data: card, isLoading, error } = api.card.byId.useQuery(
const {
data: card,
isLoading,
error,
} = api.card.byId.useQuery(
{ cardPublicId: cardId ?? "" },
{ enabled: !!cardId && cardId.length >= 12 },
);
@@ -340,14 +344,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
>
{board?.name}
</Link>
{card.cardNumber != null && card.list.board.workspace.cardPrefix && (
<>
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
</span>
</>
)}
{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
@@ -356,10 +361,13 @@ 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}`}

View File

@@ -36,6 +36,17 @@ export default function InvitePage() {
return router.push(`/boards`);
}
if (
error.data?.code === "FORBIDDEN" &&
error.message === "SEAT_LIMIT_REACHED"
) {
setError(
t`This workspace has reached its member limit. The workspace owner will need to upgrade their plan.`,
);
setIsProcessing(false);
return;
}
setError(
error.message ||
t`Failed to accept invitation. Please try again later, or contact customer support.`,

View File

@@ -13,7 +13,6 @@ import { z } from "zod";
import type { InviteMemberInput } from "@kan/api/types";
import type { Subscription } from "@kan/shared/utils";
import { authClient } from "@kan/auth/client";
import { getSubscriptionByPlan } from "@kan/shared/utils";
import Button from "~/components/Button";
@@ -25,15 +24,15 @@ import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function InviteMemberForm({
numberOfMembers,
subscriptions,
unlimitedSeats,
userId,
memberCount,
seatLimit,
}: {
numberOfMembers: number;
subscriptions: Subscription[] | undefined;
unlimitedSeats: boolean;
userId: string | undefined;
memberCount: number;
seatLimit: number | null;
}) {
const utils = api.useUtils();
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
@@ -98,6 +97,15 @@ export function InviteMemberForm({
message: t`User is already a member of this workspace`,
icon: "error",
});
} else if (
error.data?.code === "FORBIDDEN" &&
error.message === "SEAT_LIMIT_REACHED"
) {
showPopup({
header: t`Seat limit reached`,
message: t`You've reached your ${seatLimit ?? 0}-seat limit. Please upgrade your plan to add more members.`,
icon: "error",
});
} else {
showPopup({
header: t`Error inviting member`,
@@ -153,6 +161,9 @@ export function InviteMemberForm({
const hasTeamSubscription = !!teamSubscription;
const hasProSubscription = !!proSubscription;
const isPartnerTier = !!(
teamSubscription?.partnerTier ?? proSubscription?.partnerTier
);
let isYearly = false;
let price = t`$10/month`;
@@ -175,13 +186,13 @@ export function InviteMemberForm({
inviteMember.mutate(member);
};
const isFreePlan =
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription;
const handleInviteLinkToggle = async () => {
if (
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
)
return handleUpgrade();
if (isFreePlan) return;
setIsLoadingInviteLink(true);
@@ -219,31 +230,6 @@ export function InviteMemberForm({
}
};
const handleUpgrade = async () => {
const { data, error } = await authClient.subscription.upgrade({
plan: "team",
referenceId: workspace.publicId,
metadata: { userId },
seats: numberOfMembers,
successUrl: "/members",
cancelUrl: "/members",
returnUrl: "/members",
disableRedirect: true,
});
if (data?.url) {
window.location.href = data.url;
}
if (error) {
showPopup({
header: t`Error upgrading subscription`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
}
};
useEffect(() => {
const emailElement: HTMLElement | null =
document.querySelector<HTMLElement>("#email");
@@ -270,11 +256,7 @@ export function InviteMemberForm({
<Input
id="email"
placeholder={t`Email`}
disabled={
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
}
disabled={isFreePlan}
{...register("email", { required: true })}
onKeyDown={async (e) => {
if (e.key === "Enter") {
@@ -285,78 +267,92 @@ export function InviteMemberForm({
errorMessage={errors.email?.message}
/>
)}
{(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
<div className="my-4">
<div className="relative">
<Input
value={inviteLink}
className="pr-10 text-sm text-light-900 dark:text-dark-900"
readOnly
/>
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
onClick={copyToClipboard}
>
{copied ? (
<HiMiniCheck className="h-5 w-5 text-green-600" />
) : (
<HiOutlineDocumentDuplicate className="h-5 w-5" />
)}
</button>
{!isFreePlan &&
(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
<div className="my-4">
<div className="relative">
<Input
value={inviteLink}
className="pr-10 text-sm text-light-900 dark:text-dark-900"
readOnly
/>
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
onClick={copyToClipboard}
>
{copied ? (
<HiMiniCheck className="h-5 w-5 text-green-600" />
) : (
<HiOutlineDocumentDuplicate className="h-5 w-5" />
)}
</button>
</div>
<div className="mt-2 flex items-start gap-1">
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
<p className="text-xs text-gray-500 dark:text-dark-900">
{t`Anyone with this link can join your workspace`}
</p>
</div>
</div>
<div className="mt-2 flex items-start gap-1">
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
<p className="text-xs text-gray-500 dark:text-dark-900">
{t`Anyone with this link can join your workspace`}
</p>
</div>
</div>
)}
)}
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
{hasTeamSubscription || hasProSubscription ? (
<div>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
(isPartnerTier && seatLimit !== null ? (
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
<div className="flex items-center justify-between">
<span className="font-medium text-emerald-500 dark:text-emerald-400">
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan`}
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan`}
</span>
<p className="mt-1">
{unlimitedSeats
? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
</p>
</div>
) : (
<div>
<span className="font-medium text-light-950 dark:text-dark-950">
{t`Free Plan`}
<span className="text-light-900 dark:text-dark-900">
{memberCount} / {seatLimit} {t`seats`}
</span>
<p className="mt-1">
{t`Inviting members requires a Team Plan. You'll be redirected to upgrade your workspace.`}
</p>
</div>
)}
</div>
)}
</div>
) : !unlimitedSeats ? (
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
{hasTeamSubscription || hasProSubscription ? (
<div>
<span className="font-medium text-emerald-500 dark:text-emerald-400">
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
</span>
<p className="mt-1">
{t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
</p>
</div>
) : (
<div>
<span className="font-medium text-light-950 dark:text-dark-950">
{t`Free Plan`}
</span>
<p className="mt-1">
{t`Inviting members requires a Team or Pro plan. You'll be redirected to upgrade your workspace.`}
</p>
</div>
)}
</div>
) : null)}
</div>
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={
isShareInviteLinkEnabled
? t`Deactivate invite link`
: t`Create invite link`
}
isChecked={isShareInviteLinkEnabled}
onChange={handleInviteLinkToggle}
/>
{!isFreePlan && (
<Toggle
label={
isShareInviteLinkEnabled
? t`Deactivate invite link`
: t`Create invite link`
}
isChecked={isShareInviteLinkEnabled}
onChange={handleInviteLinkToggle}
/>
)}
<div>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription ? (
<Button type="button" onClick={handleUpgrade}>
{t`Start 14 day free trial`}
{isFreePlan ? (
<Button
type="button"
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
>
{t`Choose plan`}
</Button>
) : (
<Button

View File

@@ -11,7 +11,11 @@ import { twMerge } from "tailwind-merge";
import type { Subscription } from "@kan/shared/utils";
import { authClient } from "@kan/auth/client";
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
import {
getSeatLimit,
getSubscriptionByPlan,
hasUnlimitedSeats,
} from "@kan/shared/utils";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
@@ -83,6 +87,16 @@ export default function MembersPage() {
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,
@@ -270,11 +284,11 @@ export default function MembersPage() {
</h1>
</div>
<div className="flex items-center gap-3">
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && !!data && (
<>
{!isPaidPlan && (
<Link
href="/settings/workspace?upgrade=pro"
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
className="hidden items-center rounded-full border border-emerald-300 bg-emerald-50 px-3 py-1 text-center text-xs text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400 lg:flex"
>
<HiBolt />
@@ -295,11 +309,17 @@ export default function MembersPage() {
: isTeamPlan
? t`Team Plan`
: t`Free Plan`}
{isProPlan && 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
@@ -388,10 +408,10 @@ export default function MembersPage() {
isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
>
<InviteMemberForm
userId={session?.user.id}
numberOfMembers={data?.members.length ?? 1}
subscriptions={subscriptions}
unlimitedSeats={unlimitedSeats}
memberCount={memberCount}
seatLimit={seatLimit}
/>
</Modal>

View File

@@ -1,6 +1,6 @@
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { Radio, RadioGroup } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { HiUser } from "react-icons/hi2";
@@ -48,6 +48,7 @@ export default function SelectPlanView() {
(searchParams.get("billing") as Billing | null) ?? "annual",
);
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const workspacePublicId = searchParams.get("workspacePublicId");
const { data: workspaces } = api.workspace.all.useQuery();
const { data: session } = authClient.useSession();
const { data: user } = api.user.getUser.useQuery(undefined, {
@@ -96,18 +97,44 @@ export default function SelectPlanView() {
},
];
const buildSelectPlanUrl = (plan: PlanId, b: Billing) => {
const base = `/onboarding/select-plan?plan=${plan}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`;
return workspacePublicId
? `${base}&workspacePublicId=${workspacePublicId}`
: base;
};
const handleSelectPlan = (plan: PlanId) => {
setSelected(plan);
router.replace(`/onboarding/select-plan?plan=${plan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
router.replace(buildSelectPlanUrl(plan, billing));
};
const handleSetBilling = (b: Billing) => {
setBilling(b);
router.replace(`/onboarding/select-plan?plan=${selected}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`);
router.replace(buildSelectPlanUrl(selected, b));
};
const handleContinue = () =>
router.push(`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
const handleContinue = async () => {
if (workspacePublicId && selected !== "solo") {
const response = await fetch("/api/stripe/create_checkout_session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
plan: selected,
billing,
workspacePublicId,
successUrl: returnUrl,
cancelUrl: returnUrl,
}),
});
const { url } = (await response.json()) as { url: string };
if (url) window.location.href = url;
return;
}
router.push(
`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
);
};
const handleCancel = () => router.push(returnUrl);
@@ -209,7 +236,11 @@ export default function SelectPlanView() {
{t`Cancel`}
</Button>
)}
<Button onClick={handleContinue}>{t`Continue`}</Button>
<Button onClick={() => void handleContinue()}>
{workspacePublicId && selected !== "solo"
? t`Upgrade`
: t`Continue`}
</Button>
</div>
</div>

View File

@@ -40,7 +40,8 @@ export default function WorkspaceNameView() {
const billing = searchParams.get("billing") ?? "annual";
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const licenseKeyParam = searchParams.get("license_key");
const isLicenseFlow = !!licenseKeyParam;
const isLicenseFlow =
!!licenseKeyParam || searchParams.get("partner") === "1";
const { showPopup } = usePopup();
useEffect(() => {
@@ -103,6 +104,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");

View File

@@ -95,8 +95,28 @@ export function CardModal({
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full p-8">
<div className="mb-6">
<div className="flex w-full items-center justify-between">
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1">
<div className="flex w-full items-start justify-between gap-4">
<div className="flex-1">
{isLoading ? (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<>
{data?.cardNumber != null &&
data.list.board.workspace.cardPrefix && (
<span className="mb-1 block text-xs font-medium text-light-700 dark:text-dark-800">
{data.list.board.workspace.cardPrefix}-
{data.cardNumber}
</span>
)}
<h1 className="font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{data?.title}
</h1>
</>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-1">
<button
type="button"
onClick={handleCopyCardLink}
@@ -134,22 +154,6 @@ export function CardModal({
/>
</button>
</div>
{isLoading ? (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<>
{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">

View File

@@ -1,4 +1,6 @@
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button";
@@ -7,24 +9,49 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export default function BillingSettings() {
const { modalContentType, isOpen } = useModal();
const router = useRouter();
const isCloud = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
const { workspace } = useWorkspace();
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
);
const subscription = workspaceData?.subscriptions.find((s) =>
["active", "trialing", "past_due"].includes(s.status),
);
const planLabel = (() => {
if (!subscription) return t`Free (1 member)`;
if (subscription.unlimitedSeats) {
const name =
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
return `${name} (${t`unlimited members`})`;
}
if (subscription.seats != null) {
const name =
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
return `${name} (${subscription.seats} ${subscription.seats === 1 ? t`member` : t`members`})`;
}
return (
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1)
);
})();
const handleOpenBillingPortal = async () => {
try {
const response = await fetch("/api/stripe/create_billing_session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
});
const { url } = (await response.json()) as { url: string };
if (url) {
window.location.href = url;
}
if (url) window.location.href = url;
} catch (error) {
console.error("Error creating billing session:", error);
}
@@ -34,6 +61,28 @@ export default function BillingSettings() {
<>
<PageHead title={t`Settings | Billing`} />
<div className="border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Plan`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{planLabel}
</p>
{!subscription && isCloud && (
<div className="mb-8">
<Button
onClick={() =>
router.push(
`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`,
)
}
>
{t`Choose plan`}
</Button>
</div>
)}
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Billing`}

View File

@@ -1,11 +1,6 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { HiBolt } from "react-icons/hi2";
import type { Subscription } from "@kan/shared/utils";
import { hasActiveSubscription } from "@kan/shared/utils";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
@@ -22,38 +17,16 @@ 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`} />
@@ -107,19 +80,6 @@ export default function WorkspaceSettings() {
disabled={!canEditWorkspace}
/>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasActiveSubscription(subscriptions, "pro") &&
!hasActiveSubscription(subscriptions, "team") && (
<div className="my-8">
<Button
onClick={() => openModal("UPGRADE_TO_PRO")}
iconRight={<HiBolt />}
>
{t`Upgrade to Pro`}
</Button>
</div>
)}
<div className="border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Delete workspace`}
@@ -146,16 +106,6 @@ export default function WorkspaceSettings() {
>
<DeleteWorkspaceConfirmation />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"}
>
<UpgradeToProConfirmation
userId={data?.id ?? ""}
workspacePublicId={workspace.publicId}
/>
</Modal>
{/* Global modals */}
<Modal
modalSize="md"

View File

@@ -172,7 +172,7 @@ export default function Avatar({
method: "POST",
headers: {
"Content-Type": blob.type,
"x-original-filename": fileName,
"x-original-filename": encodeURIComponent(fileName),
},
body: blob,
},

View File

@@ -101,6 +101,7 @@ services:
- 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}

View File

@@ -5,36 +5,40 @@
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./root": {
"types": "./dist/root.d.ts",
"types": "./src/root.ts",
"default": "./src/root.ts"
},
"./trpc": {
"types": "./dist/trpc.d.ts",
"types": "./src/trpc.ts",
"default": "./src/trpc.ts"
},
"./types": {
"types": "./dist/types.d.ts",
"types": "./src/types/router.types.ts",
"default": "./src/types/router.types.ts"
},
"./openapi": {
"types": "./dist/openapi.d.ts",
"types": "./src/openapi.ts",
"default": "./src/openapi.ts"
},
"./utils/rateLimit": {
"types": "./dist/utils/rateLimit.d.ts",
"types": "./src/utils/rateLimit.ts",
"default": "./src/utils/rateLimit.ts"
},
"./utils/apiLogging": {
"types": "./dist/utils/apiLogging.d.ts",
"types": "./src/utils/apiLogging.ts",
"default": "./src/utils/apiLogging.ts"
},
"./utils/permissions": {
"types": "./dist/utils/permissions.d.ts",
"types": "./src/utils/permissions.ts",
"default": "./src/utils/permissions.ts"
},
"./utils/workspace": {
"types": "./src/utils/workspace.ts",
"default": "./src/utils/workspace.ts"
}
},
"license": "GPL-3.0",

View File

@@ -1347,7 +1347,7 @@ export const cardRouter = createTRPCRouter({
checklistId: newChecklist.id,
title: item.title,
createdBy: userId,
completed: item.completed ?? false,
completed: false,
});
}
}

View File

@@ -10,13 +10,14 @@ 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 { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { memberInviteResponseSchema } from "../schemas";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
assertCanManageMember,
assertCanManageRole,
@@ -114,6 +115,20 @@ export const memberRouter = createTRPCRouter({
});
}
}
const seatLimit = getSeatLimit(subscriptions);
if (seatLimit !== null) {
const memberCount = await memberRepo.getCountByWorkspaceId(
ctx.db,
workspace.id,
);
if (memberCount >= seatLimit) {
throw new TRPCError({
message: `SEAT_LIMIT_REACHED`,
code: "FORBIDDEN",
});
}
}
}
const existingUser = await userRepo.getByEmail(ctx.db, input.email);
@@ -644,6 +659,20 @@ export const memberRouter = createTRPCRouter({
});
}
}
const seatLimit = getSeatLimit(subscriptions);
if (seatLimit !== null) {
const memberCount = await memberRepo.getCountByWorkspaceId(
ctx.db,
workspace.id,
);
if (memberCount >= seatLimit) {
throw new TRPCError({
message: `SEAT_LIMIT_REACHED`,
code: "FORBIDDEN",
});
}
}
}
// Get the workspace role to set roleId

View File

@@ -2,19 +2,21 @@ 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 { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
workspaceListItemSchema,
workspaceDetailSchema,
workspaceWithBoardsSchema,
workspaceCreateResponseSchema,
workspaceUpdateResponseSchema,
workspaceDeleteResponseSchema,
workspaceDetailSchema,
workspaceListItemSchema,
workspaceUpdateResponseSchema,
workspaceWithBoardsSchema,
} from "../schemas";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
export const workspaceRouter = createTRPCRouter({
@@ -270,12 +272,47 @@ 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: result.plan!,
plan: (unlinkedSlot?.plan ?? result.plan!) as WorkspacePlan,
cardPrefix: result.cardPrefix!,
};
}),
@@ -412,10 +449,21 @@ export const workspaceRouter = createTRPCRouter({
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
await workspaceRepo.hardDelete(
ctx.db,
input.workspacePublicId,
);
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
const subs = await subscriptionRepo.getByReferenceId(
ctx.db,
input.workspacePublicId,
);
await Promise.all(
subs
.filter((s) => !!s.partnerLicenseKey)
.map((s) =>
subscriptionRepo.updateById(ctx.db, s.id, { referenceId: null }),
),
);
}
await workspaceRepo.hardDelete(ctx.db, input.workspacePublicId);
return { success: true };
}),
@@ -560,4 +608,38 @@ export const workspaceRouter = createTRPCRouter({
return result;
}),
hasAvailablePartnerSlot: protectedProcedure
.input(z.void())
.output(z.boolean())
.query(async ({ ctx }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const memberships = await workspaceRepo.getAllByUserId(ctx.db, userId);
const workspaceIds = memberships
.map((m) => m.workspace?.publicId)
.filter((id): id is string => !!id);
if (!workspaceIds.length) return false;
const partnerSub =
await subscriptionRepo.getFirstActivePartnerSubByWorkspaceIds(
ctx.db,
workspaceIds,
);
if (!partnerSub?.partnerLicenseKey) return false;
const unlinkedSlot =
await subscriptionRepo.getFirstUnlinkedSlotByLicenseKey(
ctx.db,
partnerSub.partnerLicenseKey,
);
return !!unlinkedSlot;
}),
});

View File

@@ -47,6 +47,7 @@ export const cardDetailSchema = z.object({
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),

View File

@@ -39,6 +39,7 @@ 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(),
});

View File

@@ -0,0 +1,40 @@
import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateUID } from "@kan/shared/utils";
export const cancelWorkspaceAccess = async (
db: dbClient,
workspacePublicId: string,
): Promise<void> => {
const workspace = await workspaceRepo.getByPublicId(db, workspacePublicId);
if (!workspace) return;
const preserveUserId = await memberRepo.getPreservableMemberId(
db,
workspace.id,
workspace.createdBy ?? null,
);
let newSlug = workspace.publicId;
if (workspace.slug !== workspace.publicId) {
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
db,
workspace.publicId,
);
if (!isPublicIdAvailable) {
newSlug = generateUID();
}
}
await Promise.all([
preserveUserId
? memberRepo.pauseMembersExcept(db, workspace.id, preserveUserId)
: memberRepo.pauseAllMembers(db, workspace.id),
workspaceRepo.update(db, workspacePublicId, {
plan: "free",
slug: newSlug,
}),
]);
};

View File

@@ -10,13 +10,49 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { sendEmail } from "@kan/email";
import { createLogger } from "@kan/logger";
import { generateUID } from "@kan/shared/utils";
const log = createLogger("auth");
import { createStripeClient } from "@kan/stripe";
import { socialProvidersPlugin } from "./providers";
import { triggerWorkflow } from "./utils";
const log = createLogger("auth");
async function cancelWorkspaceAccess(
db: dbClient,
workspacePublicId: string,
): Promise<void> {
const workspace = await workspaceRepo.getByPublicId(db, workspacePublicId);
if (!workspace) return;
const preserveUserId = await memberRepo.getPreservableMemberId(
db,
workspace.id,
workspace.createdBy ?? null,
);
let newSlug = workspace.publicId;
if (workspace.slug !== workspace.publicId) {
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
db,
workspace.publicId,
);
if (!isPublicIdAvailable) {
newSlug = generateUID();
}
}
await Promise.all([
preserveUserId
? memberRepo.pauseMembersExcept(db, workspace.id, preserveUserId)
: memberRepo.pauseAllMembers(db, workspace.id),
workspaceRepo.update(db, workspacePublicId, {
plan: "free",
slug: newSlug,
}),
]);
}
export function createPlugins(db: dbClient) {
return [
socialProvidersPlugin(),
@@ -104,7 +140,10 @@ export function createPlugins(db: dbClient) {
unlimitedSeats: true,
},
);
log.info({ subscriptionId: stripeSubscription.id }, "Pro subscription activated with unlimited seats");
log.info(
{ subscriptionId: stripeSubscription.id },
"Pro subscription activated with unlimited seats",
);
const workspace = await workspaceRepo.getByPublicId(
db,
@@ -126,35 +165,9 @@ export function createPlugins(db: dbClient) {
subscription,
cancellationDetails,
);
// for cancelled subscriptions, we need to pause all members and set their workspace plan to free
const workspace = await workspaceRepo.getByPublicId(
db,
subscription.referenceId,
);
if (workspace?.id) {
await memberRepo.pauseAllMembers(db, workspace.id);
// Reset slug to publicId, or generate a UID if publicId is taken
let newSlug = workspace.publicId;
if (workspace.slug !== workspace.publicId) {
const isPublicIdAvailable =
await workspaceRepo.isWorkspaceSlugAvailable(
db,
workspace.publicId,
);
if (!isPublicIdAvailable) {
newSlug = generateUID();
}
}
await workspaceRepo.update(db, subscription.referenceId, {
plan: "free",
slug: newSlug,
});
}
},
onSubscriptionDeleted: async ({ subscription }) => {
await cancelWorkspaceAccess(db, subscription.referenceId);
},
onSubscriptionUpdate: async ({ subscription }) => {
await triggerWorkflow(db, "subscription-updated", subscription);
@@ -183,7 +196,10 @@ export function createPlugins(db: dbClient) {
sendMagicLink: async ({ email, url }) => {
try {
const decodedUrl = decodeURIComponent(url);
log.info({ email, isInvite: decodedUrl.includes("type=invite") }, "Sending magic link");
log.info(
{ email, isInvite: decodedUrl.includes("type=invite") },
"Sending magic link",
);
if (decodedUrl.includes("type=invite")) {
let inviterName = "";
let workspaceName = "";

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "subscription_partner_license_key_idx";

File diff suppressed because it is too large Load Diff

View File

@@ -239,6 +239,13 @@
"when": 1778617946519,
"tag": "20260512203226_AddPartnerLicenseToSubscription",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1780057951781,
"tag": "20260529123231_DropPartnerLicenseKeyUniqueConstraint",
"breakpoints": true
}
]
}

View File

@@ -5,27 +5,27 @@
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./client": {
"types": "./dist/client.d.ts",
"types": "./src/client.ts",
"default": "./src/client.ts"
},
"./schema": {
"types": "./dist/schema.d.ts",
"types": "./src/schema/index.ts",
"default": "./src/schema/index.ts"
},
"./types/*": {
"types": "./dist/types/*.d.ts",
"types": "./src/types/*",
"default": "./src/types/*"
},
"./repository/*": {
"types": "./dist/repository/*.d.ts",
"types": "./src/repository/*.ts",
"default": "./src/repository/*.ts"
},
"./redis": {
"types": "./dist/redis.d.ts",
"types": "./src/redis.ts",
"default": "./src/redis.ts"
}
},

View File

@@ -107,7 +107,12 @@ 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");
@@ -321,8 +326,7 @@ 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;
@@ -486,6 +490,7 @@ export const getWithListAndMembersByPublicId = async (
dueDate: true,
createdBy: true,
cardNumber: true,
index: true,
},
with: {
labels: {

View File

@@ -1,4 +1,4 @@
import { and, count, eq, isNull } from "drizzle-orm";
import { and, count, eq, isNull, ne, or } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import type { MemberRole, MemberStatus } from "@kan/db/schema";
@@ -19,6 +19,27 @@ 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: {
@@ -71,14 +92,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: {
@@ -172,6 +193,68 @@ 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: {

View File

@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { and, asc, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { subscription } from "@kan/db/schema";
@@ -14,6 +14,10 @@ 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
@@ -28,6 +32,7 @@ export const updateById = async (
plan: subscription.plan,
status: subscription.status,
unlimitedSeats: subscription.unlimitedSeats,
referenceId: subscription.referenceId,
});
return result;
@@ -62,6 +67,24 @@ 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,
@@ -95,13 +118,63 @@ export const getByPartnerLicenseKey = async (
db: dbClient,
partnerLicenseKey: string,
) => {
const result = await db.query.subscription.findFirst({
return await db.query.subscription.findFirst({
where: eq(subscription.partnerLicenseKey, partnerLicenseKey),
});
return result;
};
export const upsertByPartnerLicenseKey = async (
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 (
db: dbClient,
partnerLicenseKey: string,
data: {
@@ -110,20 +183,13 @@ export const upsertByPartnerLicenseKey = async (
partnerTier: number;
seats: number | null;
unlimitedSeats: boolean;
referenceId?: string;
},
count: number,
) => {
const [result] = await db
.insert(subscription)
.values({
partnerLicenseKey,
...data,
referenceId: data.referenceId ?? null,
})
.onConflictDoUpdate({
target: subscription.partnerLicenseKey,
set: { ...data, updatedAt: new Date() },
})
.returning();
return result;
const rows = Array.from({ length: count }, () => ({
partnerLicenseKey,
...data,
referenceId: null,
}));
return await db.insert(subscription).values(rows).returning();
};

View File

@@ -176,6 +176,7 @@ export const getByPublicId = (db: dbClient, workspacePublicId: string) => {
name: true,
plan: true,
slug: true,
createdBy: true,
},
where: eq(workspaces.publicId, workspacePublicId),
});
@@ -239,6 +240,7 @@ export const getByPublicIdWithMembers = (
status: true,
seats: true,
unlimitedSeats: true,
partnerTier: true,
periodStart: true,
periodEnd: true,
},
@@ -313,6 +315,16 @@ 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: {

View File

@@ -5,42 +5,33 @@ 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(),
},
(table) => [
uniqueIndex("subscription_partner_license_key_idx").on(
table.partnerLicenseKey,
),
],
).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(),
}).enableRLS();
export const subscriptionsRelations = relations(subscription, ({ one }) => ({
workspace: one(workspaces, {

View File

@@ -5,7 +5,7 @@
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},

View File

@@ -5,15 +5,15 @@
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"types": "./src/utils/index.ts",
"default": "./src/utils/index.ts"
},
"./constants": {
"types": "./dist/constants/index.d.ts",
"types": "./src/constants/index.ts",
"default": "./src/constants/index.ts"
}
},

View File

@@ -16,8 +16,14 @@ 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 ?? "",
region: process.env.S3_REGION ?? "us-east-1",
endpoint: process.env.S3_ENDPOINT ?? "",
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
credentials,

View File

@@ -12,6 +12,7 @@ export interface Subscription {
status: string;
seats: number | null;
unlimitedSeats: boolean;
partnerTier: number | null;
periodStart: Date | null;
periodEnd: Date | null;
referenceId: string | null;
@@ -53,3 +54,14 @@ 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;
};