Compare commits
20 Commits
feat/virtu
...
feat/serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d8a43d3c | ||
|
|
8ed719c485 | ||
|
|
f455bec6a7 | ||
|
|
aee5391f28 | ||
|
|
c8517ed4c2 | ||
|
|
ceac4c0d81 | ||
|
|
4c5856ec24 | ||
|
|
976373a65b | ||
|
|
78b9de869f | ||
|
|
81c03b51e2 | ||
|
|
0d3d9a358f | ||
|
|
2269d23c94 | ||
|
|
e437d075e3 | ||
|
|
b422907b53 | ||
|
|
672dfe6540 | ||
|
|
ef5bf87fdf | ||
|
|
ec37f5480a | ||
|
|
7f5a1ab513 | ||
|
|
5f2d409773 | ||
|
|
210e44db5c |
@@ -5,6 +5,8 @@ checksums:
|
|||||||
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
|
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
|
||||||
"%7B0%7D/singular": 18782768c8a0b3c2078747b34fbbcd24
|
"%7B0%7D/singular": 18782768c8a0b3c2078747b34fbbcd24
|
||||||
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
||||||
|
"%7B0%7D%20has%20been%20added%20to%20your%20favorites./singular": 0f752180183c80703b3f9f39dc97195f
|
||||||
|
"%7B0%7D%20has%20been%20removed%20from%20your%20favorites./singular": 26e4b4997ad8a711d0b3e46337799454
|
||||||
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
||||||
"%7B0%7D%20not%20found/singular": a2665fc66f128fee1a80ffec61c9f1a1
|
"%7B0%7D%20not%20found/singular": a2665fc66f128fee1a80ffec61c9f1a1
|
||||||
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
|
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
|
||||||
@@ -27,6 +29,7 @@ checksums:
|
|||||||
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
|
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
|
||||||
Add%20label/singular: 0be732d46df263265935fda342097a08
|
Add%20label/singular: 0be732d46df263265935fda342097a08
|
||||||
Add%20member/singular: 11979625770516ca287e929381778e02
|
Add%20member/singular: 11979625770516ca287e929381778e02
|
||||||
|
Add%20to%20favorites/singular: 532155acb0a46f2b6c37e15afb3ad21e
|
||||||
added%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 42b0488d570626dc887c58ff669c953c
|
added%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 42b0488d570626dc887c58ff669c953c
|
||||||
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
|
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
|
||||||
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
|
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
|
||||||
@@ -35,9 +38,12 @@ checksums:
|
|||||||
added%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: fb6a852c50c1629f47c6f155fc93fdee
|
added%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: fb6a852c50c1629f47c6f155fc93fdee
|
||||||
added%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: bdd202da20b1fffbec21792c5453f90c
|
added%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: bdd202da20b1fffbec21792c5453f90c
|
||||||
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
|
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
|
||||||
|
Added%20to%20favorites/singular: 848046ec65d15913112b466c544722eb
|
||||||
Adding%20a%20new%20member%20will%20cost%20an%20additional%20%7Bprice%7D%20(%7BbillingType%7D)%20per%20seat./singular: 12e88573028306110fbc15ef1e714892
|
Adding%20a%20new%20member%20will%20cost%20an%20additional%20%7Bprice%7D%20(%7BbillingType%7D)%20per%20seat./singular: 12e88573028306110fbc15ef1e714892
|
||||||
Adjust%20the%20square%20crop%20to%20fit%20your%20avatar./singular: a4df26bbce6f14c6962fac1324db00a8
|
Adjust%20the%20square%20crop%20to%20fit%20your%20avatar./singular: a4df26bbce6f14c6962fac1324db00a8
|
||||||
|
Admin/singular: 90eb20f1400db82ab874744e47836dc6
|
||||||
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
||||||
|
All%20member%20permission%20overrides%20have%20been%20reset%20to%20their%20role%20defaults./singular: e5c38724a283373506d53afd22b6d096
|
||||||
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
|
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
|
||||||
Allow%20workspace%20members%20to%20see%20each%20other's%20email%20addresses/singular: 0077436d9f37bfd64f3ae076a5a05040
|
Allow%20workspace%20members%20to%20see%20each%20other's%20email%20addresses/singular: 0077436d9f37bfd64f3ae076a5a05040
|
||||||
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
|
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
|
||||||
@@ -88,6 +94,30 @@ checksums:
|
|||||||
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
||||||
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
||||||
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
||||||
|
Can%20add%20comments/singular: d7d70b75780156312701f4c5eed1b285
|
||||||
|
Can%20create%20boards/singular: 8538236f8caafd4eaa751b4ec45f1699
|
||||||
|
Can%20create%20cards/singular: dd1cce2d52e0fb261676750bfc46da22
|
||||||
|
Can%20create%20lists/singular: 53c3c1343efff494640b1227e4444de2
|
||||||
|
Can%20delete%20boards/singular: 818ff50f9f21d8ed3741a7933d66b794
|
||||||
|
Can%20delete%20cards/singular: c62309a7e52958aa4bdb477c07d1855d
|
||||||
|
Can%20delete%20comments/singular: 99884fcaf89c47aa33c6ddfd9f94ea5f
|
||||||
|
Can%20delete%20lists/singular: 8d6ff6cd43c6b9fcbc9fc7954d19409e
|
||||||
|
Can%20delete%20workspace/singular: 41bd5a6636500b1a6e04184e6f566198
|
||||||
|
Can%20edit%20boards/singular: 8b77fabfd955d5507b1826dc4a5c1108
|
||||||
|
Can%20edit%20cards/singular: 47e6c92c1056fd6e8b517ba456b3f607
|
||||||
|
Can%20edit%20comments/singular: a0caeec2b2b64e9f371f07dabed5733d
|
||||||
|
Can%20edit%20lists/singular: 1ce5feb15139c881b615edab065b3e12
|
||||||
|
Can%20edit%20member%20roles%20and%20permissions/singular: c364e8e8866111a471f4081db0730049
|
||||||
|
Can%20edit%20workspace/singular: 9768f990844e215c06910fed250da23a
|
||||||
|
Can%20invite%20members/singular: e02e562bcb7f46008d9595e485951413
|
||||||
|
Can%20manage%20workspace%20settings/singular: 27eb18d3be5b3d813996b7d5071e0317
|
||||||
|
Can%20remove%20members/singular: 97a452b0fb4b661eaca7e5b3d5b49dcd
|
||||||
|
Can%20view%20boards/singular: 14977bbbb566f72fc74e17d99bad09bb
|
||||||
|
Can%20view%20cards/singular: 5e728083853948c9d618f2276732d5cd
|
||||||
|
Can%20view%20comments/singular: 76dbbc9ae4bff589d391c67c84ac6470
|
||||||
|
Can%20view%20lists/singular: e36331c4a49f376befc9f0aa42492b01
|
||||||
|
Can%20view%20members/singular: 5b75a467257a1db29d466c0d9ccea3df
|
||||||
|
Can%20view%20workspace/singular: 79a12c55fcd04ea69cbb85b906794e9b
|
||||||
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
|
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
|
||||||
Card/singular: bba0beaced7ea954ceb980f2b022ffee
|
Card/singular: bba0beaced7ea954ceb980f2b022ffee
|
||||||
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
||||||
@@ -99,6 +129,9 @@ checksums:
|
|||||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||||
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||||
Checklists/singular: 6f79129c8f08ee54d858a2af57d16dd9
|
Checklists/singular: 6f79129c8f08ee54d858a2af57d16dd9
|
||||||
|
Clear%20all%20custom%20permissions%3F/singular: 31d0962985c83a29558c98a765bb1b16
|
||||||
|
Clear%20any%20custom%20member%20permissions%20so%20that%20all%20members%20only%20inherit%20permissions%20from%20their%20role%20defaults./singular: e493291818059bfd51f2c87b938616c4
|
||||||
|
Clear%20custom%20permissions/singular: 288ce8688fd09c5a01eda5a6ba4bc98a
|
||||||
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
||||||
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
||||||
Close/singular: 2c2e22f8424a1031de89063bd0022e16
|
Close/singular: 2c2e22f8424a1031de89063bd0022e16
|
||||||
@@ -113,6 +146,7 @@ checksums:
|
|||||||
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
||||||
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
||||||
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
|
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
|
||||||
|
Configure%20which%20actions%20are%20allowed%20for%20each%20workspace%20role.%20These%20permissions%20apply%20to%20all%20members%20with%20that%20role./singular: 0a46a8a30c6c0ebdcd01e72a7d64ec64
|
||||||
Confirm%20your%20email%20preferences%3A/singular: 043c161dd9866231ae2418fdd9f61b9c
|
Confirm%20your%20email%20preferences%3A/singular: 043c161dd9866231ae2418fdd9f61b9c
|
||||||
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
|
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
|
||||||
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
|
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
|
||||||
@@ -146,6 +180,7 @@ checksums:
|
|||||||
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
||||||
Custom%20board%20templates/singular: c2966b352d76bc53421c01e9c99474bb
|
Custom%20board%20templates/singular: c2966b352d76bc53421c01e9c99474bb
|
||||||
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
||||||
|
Custom%20permissions/singular: 6f1748601979e2e4548877b292b43a20
|
||||||
Custom%20templates/singular: f8caaad67e168f106a298c8e0a66240c
|
Custom%20templates/singular: f8caaad67e168f106a298c8e0a66240c
|
||||||
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
|
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
|
||||||
Custom%20workspace%20link/singular: 8a19ae46ccea9c54b65ae183cea70b44
|
Custom%20workspace%20link/singular: 8a19ae46ccea9c54b65ae183cea70b44
|
||||||
@@ -189,6 +224,7 @@ checksums:
|
|||||||
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
||||||
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
||||||
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
||||||
|
Edit%20permissions/singular: 244558dd716491b7ed72ba8ab73aa28f
|
||||||
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
|
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
|
||||||
Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b
|
Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b
|
||||||
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
|
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
|
||||||
@@ -262,6 +298,7 @@ checksums:
|
|||||||
Go%20to%20members/singular: 445f4efbc4b1e7509f4fd79ebfbb1476
|
Go%20to%20members/singular: 445f4efbc4b1e7509f4fd79ebfbb1476
|
||||||
Go%20to%20settings/singular: 24a7f96880650c9b37099d69f4b7e2a9
|
Go%20to%20settings/singular: 24a7f96880650c9b37099d69f4b7e2a9
|
||||||
Go%20to%20templates/singular: e4e58e33d637282d141df466d729bc7c
|
Go%20to%20templates/singular: e4e58e33d637282d141df466d729bc7c
|
||||||
|
Guest/singular: 2aec6d6ebe0d9a1db0a5c8cd5a98b8c3
|
||||||
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
||||||
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
||||||
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
|
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
|
||||||
@@ -323,6 +360,7 @@ checksums:
|
|||||||
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
||||||
Lists/singular: 9f4a73afc8de321175d71935134ef066
|
Lists/singular: 9f4a73afc8de321175d71935134ef066
|
||||||
Load%20more%20activities/singular: f32d40a739ffaa700051c4c7d70055cf
|
Load%20more%20activities/singular: f32d40a739ffaa700051c4c7d70055cf
|
||||||
|
Loading%20permissions.../singular: a5665279d4e439186825057c32d4d976
|
||||||
Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66
|
Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66
|
||||||
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
||||||
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
||||||
@@ -334,6 +372,7 @@ checksums:
|
|||||||
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
|
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
|
||||||
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
|
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
|
||||||
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
||||||
|
Member/singular: 1606dc30b369856b9dba1fe9aec425d2
|
||||||
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
|
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||||
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
|
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
|
||||||
Monthly/singular: 818f1192e32bb855597f930d3e78806e
|
Monthly/singular: 818f1192e32bb855597f930d3e78806e
|
||||||
@@ -366,7 +405,9 @@ checksums:
|
|||||||
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
|
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
|
||||||
No%20keyboard%20shortcuts%20registered./singular: 7f1ed5d777cade7d62303e9e591bbf63
|
No%20keyboard%20shortcuts%20registered./singular: 7f1ed5d777cade7d62303e9e591bbf63
|
||||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||||
|
No%20lists%20have%20been%20created%20yet/singular: f18ee3d7230cc33b68bd17b429d1d442
|
||||||
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
|
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
|
||||||
|
No%20roles%20found%20for%20this%20workspace%20yet./singular: 2eb502333aaf6c58e8ab23d881e2e357
|
||||||
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||||
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
||||||
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
||||||
@@ -379,6 +420,7 @@ checksums:
|
|||||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||||
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
|
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
|
||||||
Overdue/singular: 24caaa2b5d7a2447ab7664e3771cf98c
|
Overdue/singular: 24caaa2b5d7a2447ab7664e3771cf98c
|
||||||
|
Overrides%20cleared/singular: f2e380efbae31a6113cc0cbf0eadf7b0
|
||||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||||
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
||||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||||
@@ -391,6 +433,10 @@ checksums:
|
|||||||
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
|
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
|
||||||
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
|
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
|
||||||
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
||||||
|
Permission/singular: cc2ed7274bd8267f9e0a10b079584d8b
|
||||||
|
Permissions/singular: 2160be68b1d6b6577e64634e9feba2ed
|
||||||
|
Permissions%20reset/singular: dd4776f04aca858deb95887e807570fc
|
||||||
|
Permissions%20updated/singular: 0df44570b783b8b284610da8627d333d
|
||||||
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
||||||
Planning/singular: 353f58c75248275fe091740607501610
|
Planning/singular: 353f58c75248275fe091740607501610
|
||||||
Platform/singular: c68862170146325333c7f25af11a3fa2
|
Platform/singular: c68862170146325333c7f25af11a3fa2
|
||||||
@@ -419,22 +465,26 @@ checksums:
|
|||||||
Recruitment/singular: 78084ee4954aa22efe9ad7463d2a0c72
|
Recruitment/singular: 78084ee4954aa22efe9ad7463d2a0c72
|
||||||
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
|
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
|
||||||
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
|
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
|
||||||
|
Remove%20from%20favorites/singular: 74d9cc2d2f3c7a7dc42a7e62c8ee06d5
|
||||||
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
|
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
|
||||||
removed%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 612fcfd4aca6818fc870f81d9687d63f
|
removed%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 612fcfd4aca6818fc870f81d9687d63f
|
||||||
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
|
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
|
||||||
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
||||||
|
Removed%20from%20favorites/singular: 540609ab686ed67b1cfd7649467b8bc1
|
||||||
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
|
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
|
||||||
removed%20the%20due%20date/singular: 000c084844718540fcad542dd8caf8b4
|
removed%20the%20due%20date/singular: 000c084844718540fcad542dd8caf8b4
|
||||||
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
|
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
|
||||||
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
|
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
|
||||||
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
|
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
|
||||||
Research/singular: 3368e9638d1619babd6df9fad592274f
|
Research/singular: 3368e9638d1619babd6df9fad592274f
|
||||||
|
Reset%20to%20role%20defaults/singular: fc9ff8ea0503e50da3e9b3e5d8bb75dd
|
||||||
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
||||||
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
||||||
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
|
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
|
||||||
Review/singular: 299f75db25382980b2895622d7712927
|
Review/singular: 299f75db25382980b2895622d7712927
|
||||||
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
||||||
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
||||||
|
Role%20updated/singular: 73606ae9c35101f1bb518961f68559d0
|
||||||
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
|
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
|
||||||
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
|
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
|
||||||
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||||
@@ -456,6 +506,7 @@ checksums:
|
|||||||
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
|
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
|
||||||
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
|
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
|
||||||
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
|
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
|
||||||
|
Settings%20%7C%20Permissions/singular: 8aa60ed978b9f45a705d99dc1ee04f37
|
||||||
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
|
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
|
||||||
Shortcuts/singular: db3330ed3240c398054f3be23c52851f
|
Shortcuts/singular: db3330ed3240c398054f3be23c52851f
|
||||||
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
||||||
@@ -490,6 +541,8 @@ checksums:
|
|||||||
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
||||||
The%20current%20password%20you%20entered%20is%20incorrect./singular: 67a76bf346ab4b48563269e9d941a64a
|
The%20current%20password%20you%20entered%20is%20incorrect./singular: 67a76bf346ab4b48563269e9d941a64a
|
||||||
The%20main%20difference%20between%20Kan%20and%20Trello%20is%20that%20Kan%20is%20open%20source%2C%20allowing%20anyone%20to%20view%2C%20modify%2C%20and%20contribute%20to%20our%20code.%20Our%20cloud%20offering%20also%20offers%20no%20restrictions%20on%20features%20for%20individual%20use%2C%20whereas%20Trello%20locks%20basic%20features%20such%20as%20the%20number%20of%20boards%20you%20can%20create%20behind%20a%20paywall./singular: db6e22cc955c5fbe547feedeb48f8719
|
The%20main%20difference%20between%20Kan%20and%20Trello%20is%20that%20Kan%20is%20open%20source%2C%20allowing%20anyone%20to%20view%2C%20modify%2C%20and%20contribute%20to%20our%20code.%20Our%20cloud%20offering%20also%20offers%20no%20restrictions%20on%20features%20for%20individual%20use%2C%20whereas%20Trello%20locks%20basic%20features%20such%20as%20the%20number%20of%20boards%20you%20can%20create%20behind%20a%20paywall./singular: db6e22cc955c5fbe547feedeb48f8719
|
||||||
|
The%20member's%20permissions%20have%20been%20updated./singular: 6ae91be2c5c0504bf521335094c50fa4
|
||||||
|
The%20member's%20role%20has%20been%20updated./singular: e30c30a2beaac7d046c35f628102f83b
|
||||||
The%20open%20source%20%3C0%2F%3E%20alternative%20to%20Trello/singular: 692cb2e8a8e610953c996826beed45a2
|
The%20open%20source%20%3C0%2F%3E%20alternative%20to%20Trello/singular: 692cb2e8a8e610953c996826beed45a2
|
||||||
The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
|
The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
|
||||||
Theme/singular: 21fe00b7a518089576fb83c08631107a
|
Theme/singular: 21fe00b7a518089576fb83c08631107a
|
||||||
@@ -499,6 +552,8 @@ checksums:
|
|||||||
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
|
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
|
||||||
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
|
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
|
||||||
This%20invitation%20link%20is%20invalid%20or%20has%20expired./singular: 11cc7ef8f1512e7e058e1fbbe5644001
|
This%20invitation%20link%20is%20invalid%20or%20has%20expired./singular: 11cc7ef8f1512e7e058e1fbbe5644001
|
||||||
|
This%20member's%20permissions%20have%20been%20reset%20to%20their%20role%20defaults./singular: 730f33b8f1fc002c4f393ccd262b6664
|
||||||
|
This%20will%20remove%20all%20custom%20member%20permissions%20in%20this%20workspace.%20Members%20will%20inherit%20permissions%20only%20from%20their%20roles./singular: 1c989752736fa41ecdd68ea890f16a15
|
||||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
|
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
|
||||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
|
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
|
||||||
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
|
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
|
||||||
@@ -516,6 +571,7 @@ checksums:
|
|||||||
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
||||||
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
||||||
Unable%20to%20add%20label/singular: b09fda6420ea1dc10dbea0b1dc373f29
|
Unable%20to%20add%20label/singular: b09fda6420ea1dc10dbea0b1dc373f29
|
||||||
|
Unable%20to%20clear%20overrides/singular: dfeac280858332082ad34d2623a59863
|
||||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
||||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||||
@@ -528,7 +584,9 @@ checksums:
|
|||||||
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
||||||
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
|
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
|
||||||
Unable%20to%20reorder%20checklist%20item/singular: dcc238c72b85daf74ebd46adcd914473
|
Unable%20to%20reorder%20checklist%20item/singular: dcc238c72b85daf74ebd46adcd914473
|
||||||
|
Unable%20to%20reset%20permissions/singular: fb09d88ff4eb32afb852d733bafd515b
|
||||||
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
|
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
|
||||||
|
Unable%20to%20update%20board/singular: 62899897d7bffcb1df3af77d460f0881
|
||||||
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
||||||
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
||||||
Unable%20to%20update%20card/singular: eca6002e57af8ec97324eae64e3b5731
|
Unable%20to%20update%20card/singular: eca6002e57af8ec97324eae64e3b5731
|
||||||
@@ -539,6 +597,8 @@ checksums:
|
|||||||
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
||||||
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
||||||
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
|
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
|
||||||
|
Unable%20to%20update%20permissions/singular: 134f20c5f2463167509562b284ef1c61
|
||||||
|
Unable%20to%20update%20role/singular: 4f2240aeff6f7275feb33ca3f608e48c
|
||||||
unassigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20from%20the%20card/singular: b683cc07092348c1e75dba40b1262b85
|
unassigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20from%20the%20card/singular: b683cc07092348c1e75dba40b1262b85
|
||||||
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
|
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
|
||||||
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
|
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
|
||||||
@@ -597,6 +657,7 @@ checksums:
|
|||||||
Workspace%20name%20is%20required/singular: b8c5162dd08c4d941bc57f9d0cbee451
|
Workspace%20name%20is%20required/singular: b8c5162dd08c4d941bc57f9d0cbee451
|
||||||
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
|
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
|
||||||
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
|
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
|
||||||
|
Workspace%20permissions/singular: 72c0202f30e543eb81bf930d85647096
|
||||||
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
|
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
|
||||||
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
|
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
|
||||||
workspace-url/singular: 2d034732ec536f3a2667f956fa50d394
|
workspace-url/singular: 2d034732ec536f3a2667f956fa50d394
|
||||||
@@ -607,10 +668,12 @@ checksums:
|
|||||||
You%20can%20get%20a%20custom%20workspace%20URL%2C%20like%20%3C0%3Ekan.bn%2Fkan%3C%2F0%3E%2C%20by%20going%20into%20your%20%3C1%3Eworkspace%20settings%3C%2F1%3E%20and%20purchasing%20a%20pro%20workspace%20subscription.%20All%20subscriptions%20help%20fund%20the%20development%20of%20the%20project!/singular: 41dd145ef56f539e12dc064a9807c660
|
You%20can%20get%20a%20custom%20workspace%20URL%2C%20like%20%3C0%3Ekan.bn%2Fkan%3C%2F0%3E%2C%20by%20going%20into%20your%20%3C1%3Eworkspace%20settings%3C%2F1%3E%20and%20purchasing%20a%20pro%20workspace%20subscription.%20All%20subscriptions%20help%20fund%20the%20development%20of%20the%20project!/singular: 41dd145ef56f539e12dc064a9807c660
|
||||||
You%20can%20invite%20team%20members%20by%20clicking%20the%20%22Invite%22%20button%20in%20the%20top%20right%20corner%20of%20the%20%3C0%3Emembers%20page%3C%2F0%3E%20and%20entering%20their%20email%20address.%20They%20will%20receive%20an%20email%20with%20a%20link%20to%20join%20the%20workspace./singular: 47e125eb9b4c11cab5a2f4b3ab08e883
|
You%20can%20invite%20team%20members%20by%20clicking%20the%20%22Invite%22%20button%20in%20the%20top%20right%20corner%20of%20the%20%3C0%3Emembers%20page%3C%2F0%3E%20and%20entering%20their%20email%20address.%20They%20will%20receive%20an%20email%20with%20a%20link%20to%20join%20the%20workspace./singular: 47e125eb9b4c11cab5a2f4b3ab08e883
|
||||||
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
|
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
|
||||||
|
You%20don't%20have%20permission/singular: 11d928b1993d95d54a95f85f8ae5016d
|
||||||
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
|
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
|
||||||
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
||||||
You%20have%20been%20unsubscribed!/singular: e0b9985faa4e7f25b71acc77750923a6
|
You%20have%20been%20unsubscribed!/singular: e0b9985faa4e7f25b71acc77750923a6
|
||||||
You%20have%20unlimited%20seats%20with%20your%20Pro%20Plan.%20There%20is%20no%20additional%20charge%20for%20new%20members!/singular: e3dc59a5ba7211cd3d8516b3a79d85ca
|
You%20have%20unlimited%20seats%20with%20your%20Pro%20Plan.%20There%20is%20no%20additional%20charge%20for%20new%20members!/singular: e3dc59a5ba7211cd3d8516b3a79d85ca
|
||||||
|
You%20need%20to%20be%20an%20admin%20to%20manage%20workspace%20permissions./singular: e2e816f2b7a13b1056d79e7f25088664
|
||||||
You've%20been%20invited%20to%20join%20a%20workspace%20on%20kan.bn./singular: 257b840726f972f384243a72767f880f
|
You've%20been%20invited%20to%20join%20a%20workspace%20on%20kan.bn./singular: 257b840726f972f384243a72767f880f
|
||||||
You've%20been%20invited%20to%20join%20a%20workspace./singular: 24fc6cdc8740f37a83df85f582f03293
|
You've%20been%20invited%20to%20join%20a%20workspace./singular: 24fc6cdc8740f37a83df85f582f03293
|
||||||
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ const config = {
|
|||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: 'cdn.discordapp.com',
|
hostname: 'cdn.discordapp.com',
|
||||||
pathname: '/avatars/**',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -95,12 +94,6 @@ const config = {
|
|||||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||||
},
|
},
|
||||||
|
|
||||||
api: {
|
|
||||||
bodyParser: {
|
|
||||||
sizeLimit: env("NEXT_API_BODY_SIZE_LIMIT") || '1mb',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
"@trpc/server": "catalog:",
|
"@trpc/server": "catalog:",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"framer-motion": "^12.26.2",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"jose": "^6.1.2",
|
"jose": "^6.1.2",
|
||||||
"next": "15.5.9",
|
"next": "15.5.9",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Avatar = ({
|
|||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const initials = name
|
const initials = name?.trim()
|
||||||
? getInitialsFromName(name)
|
? getInitialsFromName(name)
|
||||||
: inferInitialsFromEmail(email);
|
: inferInitialsFromEmail(email);
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface CheckboxDropdownProps {
|
|||||||
handleEdit?: (key: string) => void;
|
handleEdit?: (key: string) => void;
|
||||||
handleCreate?: () => void;
|
handleCreate?: () => void;
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CheckboxDropdown({
|
export default function CheckboxDropdown({
|
||||||
@@ -44,6 +45,7 @@ export default function CheckboxDropdown({
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleCreate,
|
handleCreate,
|
||||||
asChild = true,
|
asChild = true,
|
||||||
|
disabled = false,
|
||||||
}: CheckboxDropdownProps) {
|
}: CheckboxDropdownProps) {
|
||||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -58,13 +60,13 @@ export default function CheckboxDropdown({
|
|||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<Menu.Item key={item.key}>
|
<Menu.Item key={item.key}>
|
||||||
<div
|
<div
|
||||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id={item.key}
|
id={item.key}
|
||||||
name={item.key}
|
name={item.key}
|
||||||
@@ -132,7 +134,8 @@ export default function CheckboxDropdown({
|
|||||||
<>
|
<>
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
as={asChild ? "div" : undefined}
|
as={asChild ? "div" : undefined}
|
||||||
className="h-full w-full cursor-pointer focus-visible:outline-none"
|
disabled={disabled}
|
||||||
|
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { authClient } from "@kan/auth/client";
|
|||||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
import SideNavigation from "./SideNavigation";
|
import SideNavigation from "./SideNavigation";
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
@@ -44,6 +45,12 @@ export default function Dashboard({
|
|||||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||||
|
|
||||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||||
|
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
enabled: !!session?.user,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||||
@@ -155,8 +162,12 @@ export default function Dashboard({
|
|||||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||||
>
|
>
|
||||||
<SideNavigation
|
<SideNavigation
|
||||||
user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
|
user={{
|
||||||
isLoading={sessionLoading}
|
displayName: user?.name ?? session?.user.name,
|
||||||
|
email: user?.email ?? session?.user.email ?? "",
|
||||||
|
image: user?.image ?? undefined,
|
||||||
|
}}
|
||||||
|
isLoading={sessionLoading || userLoading}
|
||||||
onCloseSideNav={closeSideNav}
|
onCloseSideNav={closeSideNav}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function Dropdown({
|
|||||||
children,
|
children,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
items: { label: string; action: () => void; icon?: React.ReactNode }[];
|
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -30,13 +30,14 @@ export default function Dropdown({
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Menu.Item key={item.label}>
|
<Menu.Item key={item.label} disabled={item.disabled}>
|
||||||
<button
|
<button
|
||||||
onClick={item.action}
|
onClick={item.action}
|
||||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
|
disabled={item.disabled ?? !item.action}
|
||||||
|
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
|
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end m-3"
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||||
<Transition
|
<Transition
|
||||||
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
|
|||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
<div className="pointer-events-auto w-full max-w-[350px] overflow-hidden rounded-xl border border-light-400 bg-light-50 shadow-lg ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-100 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||||
<div className="p-4">
|
<div className="p-4 relative">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 mt-1">
|
||||||
{popupIcon === "success" && (
|
{popupIcon === "success" && (
|
||||||
<HiOutlineCheckCircle
|
<HiOutlineCheckCircle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-6 w-6 text-green-400"
|
className="h-5 w-5 text-green-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{popupIcon === "error" && (
|
{popupIcon === "error" && (
|
||||||
<HiOutlineExclamationCircle
|
<HiOutlineExclamationCircle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-6 w-6 text-red-400"
|
className="h-5 w-5 text-red-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||||
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
|
||||||
{popupHeader}
|
{popupHeader}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
|
<p className="mt-1 text-[12px] text-neutral-500 dark:text-dark-900">
|
||||||
{popupMessage}
|
{popupMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex flex-shrink-0">
|
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-400"
|
className="inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-200"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
<HiXMark
|
<HiXMark
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-5 w-5 text-dark-900"
|
className="h-4 w-4 text-dark-900"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ import {
|
|||||||
HiOutlineBanknotes,
|
HiOutlineBanknotes,
|
||||||
HiOutlineCodeBracketSquare,
|
HiOutlineCodeBracketSquare,
|
||||||
HiOutlineRectangleGroup,
|
HiOutlineRectangleGroup,
|
||||||
|
HiOutlineShieldCheck,
|
||||||
HiOutlineUser,
|
HiOutlineUser,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
|
|
||||||
interface SettingsLayoutProps {
|
interface SettingsLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -24,8 +27,12 @@ interface SettingsLayoutProps {
|
|||||||
|
|
||||||
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
|
const { canViewWorkspace, canEditWorkspace } = usePermissions();
|
||||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||||
|
|
||||||
|
const isAdmin = workspace.role === "admin";
|
||||||
|
|
||||||
const settingsTabs = [
|
const settingsTabs = [
|
||||||
{
|
{
|
||||||
key: "account",
|
key: "account",
|
||||||
@@ -37,13 +44,19 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
key: "workspace",
|
key: "workspace",
|
||||||
icon: <HiOutlineRectangleGroup />,
|
icon: <HiOutlineRectangleGroup />,
|
||||||
label: t`Workspace`,
|
label: t`Workspace`,
|
||||||
condition: true,
|
condition: canViewWorkspace,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "permissions",
|
||||||
|
icon: <HiOutlineShieldCheck />,
|
||||||
|
label: t`Permissions`,
|
||||||
|
condition: isAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "billing",
|
key: "billing",
|
||||||
label: t`Billing`,
|
label: t`Billing`,
|
||||||
icon: <HiOutlineBanknotes />,
|
icon: <HiOutlineBanknotes />,
|
||||||
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
|
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "api",
|
key: "api",
|
||||||
@@ -55,7 +68,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
key: "integrations",
|
key: "integrations",
|
||||||
icon: <HiOutlineCodeBracketSquare />,
|
icon: <HiOutlineCodeBracketSquare />,
|
||||||
label: t`Integrations`,
|
label: t`Integrations`,
|
||||||
condition: true,
|
condition: canEditWorkspace,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -97,7 +110,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
>
|
>
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
||||||
{availableTabs[selectedTabIndex]?.label || "Select a tab"}
|
{availableTabs[selectedTabIndex]?.label ?? "Select a tab"}
|
||||||
<HiChevronDown
|
<HiChevronDown
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
||||||
|
|||||||
@@ -55,9 +55,10 @@ export default function SideNavigation({
|
|||||||
const [isInitialised, setIsInitialised] = useState(false);
|
const [isInitialised, setIsInitialised] = useState(false);
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||||
workspacePublicId: workspace.publicId,
|
{ workspacePublicId: workspace.publicId },
|
||||||
});
|
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
const subscriptions = workspaceData?.subscriptions as
|
const subscriptions = workspaceData?.subscriptions as
|
||||||
| Subscription[]
|
| Subscription[]
|
||||||
|
|||||||
@@ -6,16 +6,20 @@ const Toggle = ({
|
|||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
disabled,
|
disabled,
|
||||||
|
showLabel = true,
|
||||||
}: {
|
}: {
|
||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<div className="mr-4 flex items-center justify-end">
|
<div className="mr-4 flex items-center justify-end">
|
||||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
{showLabel && (
|
||||||
{label}
|
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||||
</span>
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Switch
|
<Switch
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import tippy from "tippy.js";
|
|||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
content: ReactNode;
|
content?: ReactNode;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
delay?: number | [number, number];
|
delay?: number | [number, number];
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,8 @@ export function Tooltip({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!triggerRef.current) return;
|
if (!triggerRef.current) return;
|
||||||
|
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
rootRef.current = root;
|
rootRef.current = root;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function UserMenu({
|
|||||||
) : (
|
) : (
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
||||||
title={isCollapsed ? displayName ?? email : undefined}
|
title={isCollapsed ? (displayName || email) : undefined}
|
||||||
>
|
>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -104,7 +104,7 @@ export default function UserMenu({
|
|||||||
isCollapsed && "md:hidden",
|
isCollapsed && "md:hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{displayName ?? email}
|
{displayName || email}
|
||||||
</span>
|
</span>
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface Props {
|
|||||||
positionFromTop?: "sm" | "md" | "lg";
|
positionFromTop?: "sm" | "md" | "lg";
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
closeOnClickOutside?: boolean;
|
closeOnClickOutside?: boolean;
|
||||||
|
centered?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<Props> = ({
|
const Modal: React.FC<Props> = ({
|
||||||
@@ -17,6 +18,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
positionFromTop = "md",
|
positionFromTop = "md",
|
||||||
isVisible,
|
isVisible,
|
||||||
closeOnClickOutside,
|
closeOnClickOutside,
|
||||||
|
centered = false,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -60,7 +62,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
<div className={`flex min-h-full justify-center p-4 text-center sm:p-0 ${centered ? "items-center" : "items-start sm:items-start"}`}>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
@@ -71,7 +73,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel
|
<Dialog.Panel
|
||||||
className={`relative ${positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
className={`relative ${centered ? "" : positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const env = createEnv({
|
|||||||
S3_ENDPOINT: z.string().optional(),
|
S3_ENDPOINT: z.string().optional(),
|
||||||
S3_FORCE_PATH_STYLE: z.string().optional(),
|
S3_FORCE_PATH_STYLE: z.string().optional(),
|
||||||
EMAIL_FROM: z.string().optional(),
|
EMAIL_FROM: z.string().optional(),
|
||||||
REDIS_URL: z.string().url().optional(),
|
REDIS_URL: z.string().url().optional().or(z.literal("")),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
109
apps/web/src/hooks/usePermissions.ts
Normal file
109
apps/web/src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { Permission } from "@kan/shared";
|
||||||
|
import { useContext } from "react";
|
||||||
|
|
||||||
|
import { WorkspaceContext } from "~/providers/workspace";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
interface UsePermissionsResult {
|
||||||
|
permissions: Permission[];
|
||||||
|
role: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasPermission: (permission: Permission) => boolean;
|
||||||
|
canViewCard: boolean;
|
||||||
|
canCreateCard: boolean;
|
||||||
|
canEditCard: boolean;
|
||||||
|
canDeleteCard: boolean;
|
||||||
|
canCreateList: boolean;
|
||||||
|
canEditList: boolean;
|
||||||
|
canDeleteList: boolean;
|
||||||
|
canCreateBoard: boolean;
|
||||||
|
canEditBoard: boolean;
|
||||||
|
canDeleteBoard: boolean;
|
||||||
|
canViewComment: boolean;
|
||||||
|
canCreateComment: boolean;
|
||||||
|
canEditComment: boolean;
|
||||||
|
canDeleteComment: boolean;
|
||||||
|
canInviteMember: boolean;
|
||||||
|
canEditMember: boolean;
|
||||||
|
canRemoveMember: boolean;
|
||||||
|
canViewWorkspace: boolean;
|
||||||
|
canEditWorkspace: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePermissions(): UsePermissionsResult {
|
||||||
|
// Check if WorkspaceProvider is available (for public board views, it may not be)
|
||||||
|
const workspaceContext = useContext(WorkspaceContext);
|
||||||
|
|
||||||
|
// If WorkspaceProvider is not available, return safe defaults
|
||||||
|
if (!workspaceContext) {
|
||||||
|
const emptyPermissions: UsePermissionsResult = {
|
||||||
|
permissions: [],
|
||||||
|
role: null,
|
||||||
|
isLoading: false,
|
||||||
|
hasPermission: () => false,
|
||||||
|
canViewCard: false,
|
||||||
|
canCreateCard: false,
|
||||||
|
canEditCard: false,
|
||||||
|
canDeleteCard: false,
|
||||||
|
canCreateList: false,
|
||||||
|
canEditList: false,
|
||||||
|
canDeleteList: false,
|
||||||
|
canCreateBoard: false,
|
||||||
|
canEditBoard: false,
|
||||||
|
canDeleteBoard: false,
|
||||||
|
canViewComment: false,
|
||||||
|
canCreateComment: false,
|
||||||
|
canEditComment: false,
|
||||||
|
canDeleteComment: false,
|
||||||
|
canInviteMember: false,
|
||||||
|
canEditMember: false,
|
||||||
|
canRemoveMember: false,
|
||||||
|
canViewWorkspace: false,
|
||||||
|
canEditWorkspace: false,
|
||||||
|
};
|
||||||
|
return emptyPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspace } = workspaceContext;
|
||||||
|
|
||||||
|
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
|
||||||
|
{ workspacePublicId: workspace.publicId },
|
||||||
|
{
|
||||||
|
enabled: !!workspace.publicId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissions = (data?.permissions ?? []) as Permission[];
|
||||||
|
const role = data?.role ?? null;
|
||||||
|
|
||||||
|
const hasPermission = (permission: Permission): boolean => {
|
||||||
|
return permissions.includes(permission);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissions,
|
||||||
|
role,
|
||||||
|
isLoading,
|
||||||
|
hasPermission,
|
||||||
|
canViewCard: hasPermission("card:view"),
|
||||||
|
canCreateCard: hasPermission("card:create"),
|
||||||
|
canEditCard: hasPermission("card:edit"),
|
||||||
|
canDeleteCard: hasPermission("card:delete"),
|
||||||
|
canCreateList: hasPermission("list:create"),
|
||||||
|
canEditList: hasPermission("list:edit"),
|
||||||
|
canDeleteList: hasPermission("list:delete"),
|
||||||
|
canCreateBoard: hasPermission("board:create"),
|
||||||
|
canEditBoard: hasPermission("board:edit"),
|
||||||
|
canDeleteBoard: hasPermission("board:delete"),
|
||||||
|
canViewComment: hasPermission("comment:view"),
|
||||||
|
canCreateComment: hasPermission("comment:create"),
|
||||||
|
canEditComment: hasPermission("comment:edit"),
|
||||||
|
canDeleteComment: hasPermission("comment:delete"),
|
||||||
|
canInviteMember: hasPermission("member:invite"),
|
||||||
|
canEditMember: hasPermission("member:edit"),
|
||||||
|
canRemoveMember: hasPermission("member:remove"),
|
||||||
|
canViewWorkspace: hasPermission("workspace:view"),
|
||||||
|
canEditWorkspace: hasPermission("workspace:edit"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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
128
apps/web/src/pages/api/upload/attachment.ts
Normal file
128
apps/web/src/pages/api/upload/attachment.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||||
|
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||||
|
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
|
||||||
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
import { createS3Client } from "@kan/shared/utils";
|
||||||
|
import { assertPermission } from "@kan/api/utils/permissions";
|
||||||
|
|
||||||
|
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withRateLimit(
|
||||||
|
{ points: 100, duration: 60 },
|
||||||
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { user, db } = await createNextApiContext(req);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||||
|
if (!bucket) {
|
||||||
|
return res.status(500).json({ error: "Attachments bucket not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardPublicId = req.query.cardPublicId;
|
||||||
|
if (typeof cardPublicId !== "string" || cardPublicId.length < 12) {
|
||||||
|
return res.status(400).json({ error: "Invalid cardPublicId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = req.headers["content-type"];
|
||||||
|
const contentLengthHeader = req.headers["content-length"];
|
||||||
|
const contentLength = contentLengthHeader
|
||||||
|
? Number.parseInt(contentLengthHeader, 10)
|
||||||
|
: NaN;
|
||||||
|
|
||||||
|
if (typeof contentType !== "string") {
|
||||||
|
return res.status(400).json({ error: "Missing content type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||||
|
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentLength > MAX_SIZE_BYTES) {
|
||||||
|
return res.status(400).json({ error: "File too large" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalFilenameHeader =
|
||||||
|
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||||
|
|
||||||
|
const sanitizedFilename = originalFilenameHeader
|
||||||
|
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||||
|
.substring(0, 200);
|
||||||
|
|
||||||
|
// Get card and check permissions
|
||||||
|
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||||
|
db,
|
||||||
|
cardPublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!card) {
|
||||||
|
return res.status(404).json({ error: "Card not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has permission to edit the card
|
||||||
|
try {
|
||||||
|
await assertPermission(db, user.id, card.workspaceId, "card:edit");
|
||||||
|
} catch {
|
||||||
|
return res.status(403).json({ error: "Permission denied" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Key = `${card.workspaceId}/${cardPublicId}/${generateUID()}-${sanitizedFilename}`;
|
||||||
|
|
||||||
|
const client = createS3Client();
|
||||||
|
|
||||||
|
// Upload the file to S3
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: s3Key,
|
||||||
|
Body: req,
|
||||||
|
ContentType: contentType,
|
||||||
|
ContentLength: contentLength,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create attachment record and log activity
|
||||||
|
const attachment = await cardAttachmentRepo.create(db, {
|
||||||
|
cardId: card.id,
|
||||||
|
filename: sanitizedFilename,
|
||||||
|
originalFilename: originalFilenameHeader,
|
||||||
|
contentType,
|
||||||
|
size: contentLength,
|
||||||
|
s3Key,
|
||||||
|
createdBy: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await cardActivityRepo.create(db, {
|
||||||
|
type: "card.updated.attachment.added",
|
||||||
|
cardId: card.id,
|
||||||
|
createdBy: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({ attachment });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Attachment upload failed", error);
|
||||||
|
return res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
101
apps/web/src/pages/api/upload/avatar.ts
Normal file
101
apps/web/src/pages/api/upload/avatar.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
import { createS3Client } from "@kan/shared/utils";
|
||||||
|
|
||||||
|
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
|
||||||
|
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withRateLimit(
|
||||||
|
{ points: 100, duration: 60 },
|
||||||
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { user, db } = await createNextApiContext(req);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME;
|
||||||
|
if (!bucket) {
|
||||||
|
return res.status(500).json({ error: "Avatar bucket not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = req.headers["content-type"];
|
||||||
|
const contentLengthHeader = req.headers["content-length"];
|
||||||
|
const contentLength = contentLengthHeader
|
||||||
|
? Number.parseInt(contentLengthHeader, 10)
|
||||||
|
: NaN;
|
||||||
|
|
||||||
|
if (typeof contentType !== "string") {
|
||||||
|
return res.status(400).json({ error: "Missing content type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedContentTypes.includes(contentType)) {
|
||||||
|
return res.status(400).json({ error: "Invalid content type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||||
|
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentLength > MAX_SIZE_BYTES) {
|
||||||
|
return res.status(400).json({ error: "File too large" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalFilenameHeader =
|
||||||
|
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||||
|
|
||||||
|
const sanitizedFilename = originalFilenameHeader
|
||||||
|
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||||
|
.substring(0, 200);
|
||||||
|
|
||||||
|
const s3Key = `${user.id}/${sanitizedFilename}`;
|
||||||
|
|
||||||
|
const client = createS3Client();
|
||||||
|
|
||||||
|
// Upload the file to S3
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: s3Key,
|
||||||
|
Body: req,
|
||||||
|
ContentType: contentType,
|
||||||
|
ContentLength: contentLength,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update user image in database
|
||||||
|
const updatedUser = await userRepo.update(db, user.id, {
|
||||||
|
image: s3Key,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
key: s3Key,
|
||||||
|
filename: sanitizedFilename,
|
||||||
|
contentType,
|
||||||
|
size: contentLength,
|
||||||
|
user: updatedUser,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Avatar upload failed", error);
|
||||||
|
return res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
||||||
import { env as nextRuntimeEnv } from "next-runtime-env";
|
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
|
||||||
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
|
||||||
|
|
||||||
export default withRateLimit(
|
|
||||||
{ points: 100, duration: 60 },
|
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { user } = await createNextApiContext(req);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(401).json({ error: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { filename, contentType } = req.body as {
|
|
||||||
filename: string;
|
|
||||||
contentType: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Specific to avatar uploads for now
|
|
||||||
const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/;
|
|
||||||
|
|
||||||
if (!filenameRegex.test(filename)) {
|
|
||||||
return res.status(400).json({ error: "Invalid filename" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof contentType !== "string" ||
|
|
||||||
!allowedContentTypes.includes(contentType)
|
|
||||||
) {
|
|
||||||
return res.status(400).json({ error: "Invalid content type" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials =
|
|
||||||
env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
|
|
||||||
? {
|
|
||||||
accessKeyId: env.S3_ACCESS_KEY_ID,
|
|
||||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const client = new S3Client({
|
|
||||||
region: env.S3_REGION ?? "",
|
|
||||||
endpoint: env.S3_ENDPOINT ?? "",
|
|
||||||
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
|
|
||||||
credentials,
|
|
||||||
});
|
|
||||||
|
|
||||||
const signedUrl = await getSignedUrl(
|
|
||||||
client,
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
|
||||||
Key: filename,
|
|
||||||
ACL: "public-read",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.status(200).json({ url: signedUrl, key: filename });
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(500).json({ error: (error as Error).message });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import ApiSettings from "~/views/settings/ApiSettings";
|
import ApiSettings from "~/views/settings/ApiSettings";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
|
||||||
const ApiSettingsPage: NextPageWithLayout = () => {
|
const ApiSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="api">
|
<SettingsLayout currentTab="api">
|
||||||
<ApiSettings />
|
<ApiSettings />
|
||||||
|
<Popup />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import BillingSettings from "~/views/settings/BillingSettings";
|
import BillingSettings from "~/views/settings/BillingSettings";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
|
||||||
const BillingSettingsPage: NextPageWithLayout = () => {
|
const BillingSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="billing">
|
<SettingsLayout currentTab="billing">
|
||||||
<BillingSettings />
|
<BillingSettings />
|
||||||
|
<Popup />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
|
||||||
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="integrations">
|
<SettingsLayout currentTab="integrations">
|
||||||
<IntegrationsSettings />
|
<IntegrationsSettings />
|
||||||
|
<Popup />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
23
apps/web/src/pages/settings/permissions.tsx
Normal file
23
apps/web/src/pages/settings/permissions.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { NextPageWithLayout } from "~/pages/_app";
|
||||||
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
import PermissionsSettings from "~/views/settings/PermissionsSettings";
|
||||||
|
|
||||||
|
const PermissionsSettingsPage: NextPageWithLayout = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsLayout currentTab="permissions">
|
||||||
|
<PermissionsSettings />
|
||||||
|
<Popup />
|
||||||
|
</SettingsLayout>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
||||||
|
|
||||||
|
export default PermissionsSettingsPage;
|
||||||
|
|
||||||
|
|
||||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
|
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
|
||||||
const WorkspaceSettingsPage: NextPageWithLayout = () => {
|
const WorkspaceSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="workspace">
|
<SettingsLayout currentTab="workspace">
|
||||||
<WorkspaceSettings />
|
<WorkspaceSettings />
|
||||||
|
<Popup />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogBackdrop,
|
DialogBackdrop,
|
||||||
@@ -464,19 +465,37 @@ function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
|
|||||||
? stroke.modifiers.map(stringifyModifier)
|
? stroke.modifiers.map(stringifyModifier)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
modifierStrings.forEach((mod) => {
|
modifierStrings.forEach((mod, index) => {
|
||||||
parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
|
parts.push(
|
||||||
|
<kbd key={`mod-${index}-${mod}`} className={kbdClassName}>
|
||||||
|
{mod}
|
||||||
|
</kbd>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>);
|
parts.push(
|
||||||
|
<kbd key={`key-${stroke.key}`} className={kbdClassName}>
|
||||||
|
{stroke.key.toUpperCase()}
|
||||||
|
</kbd>,
|
||||||
|
);
|
||||||
|
|
||||||
return parts;
|
return parts;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (shortcut.type === "SEQUENCE") {
|
if (shortcut.type === "SEQUENCE") {
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
shortcut.strokes.forEach((stroke) => {
|
shortcut.strokes.forEach((stroke, strokeIndex) => {
|
||||||
parts.push(...formatStroke(stroke));
|
const strokeParts = formatStroke(stroke);
|
||||||
|
// Add stroke index to keys to ensure uniqueness across multiple strokes
|
||||||
|
const keyedParts = strokeParts.map((part, partIndex) => {
|
||||||
|
if (React.isValidElement(part)) {
|
||||||
|
return React.cloneElement(part, {
|
||||||
|
key: `stroke-${strokeIndex}-${part.key || partIndex}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
parts.push(...keyedParts);
|
||||||
});
|
});
|
||||||
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
|
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const initialWorkspace: Workspace = {
|
|||||||
|
|
||||||
const initialAvailableWorkspaces: Workspace[] = [];
|
const initialAvailableWorkspaces: Workspace[] = [];
|
||||||
|
|
||||||
const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export async function invalidateCard(
|
|||||||
utils: ReturnType<typeof api.useUtils>,
|
utils: ReturnType<typeof api.useUtils>,
|
||||||
cardPublicId: string,
|
cardPublicId: string,
|
||||||
) {
|
) {
|
||||||
|
if (!cardPublicId || cardPublicId.length < 12) return;
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
utils.card.byId.invalidate({ cardPublicId }),
|
utils.card.byId.invalidate({ cardPublicId }),
|
||||||
utils.card.getActivities.invalidate({ cardPublicId }),
|
utils.card.getActivities.invalidate({ cardPublicId }),
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { env } from "next-runtime-env";
|
|
||||||
|
|
||||||
export const formatToArray = (
|
export const formatToArray = (
|
||||||
value: string | string[] | undefined,
|
value: string | string[] | undefined,
|
||||||
): string[] => {
|
): string[] => {
|
||||||
@@ -52,14 +50,5 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
|||||||
return imageOrKey;
|
return imageOrKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
return "";
|
||||||
const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS");
|
|
||||||
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
|
|
||||||
|
|
||||||
if (useVirtualHostedUrls === "true" && storageDomain) {
|
|
||||||
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
|
|
||||||
return `${storageUrl}/${bucket}/${imageOrKey}`;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import {
|
|||||||
HiLink,
|
HiLink,
|
||||||
HiOutlineDocumentDuplicate,
|
HiOutlineDocumentDuplicate,
|
||||||
HiOutlineTrash,
|
HiOutlineTrash,
|
||||||
|
HiOutlineStar,
|
||||||
|
HiStar,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -15,42 +18,104 @@ export default function BoardDropdown({
|
|||||||
isTemplate,
|
isTemplate,
|
||||||
isLoading,
|
isLoading,
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
workspacePublicId,
|
isFavorite,
|
||||||
|
boardName,
|
||||||
}: {
|
}: {
|
||||||
isTemplate: boolean;
|
isTemplate: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
workspacePublicId: string;
|
isFavorite?: boolean;
|
||||||
|
boardName?: string;
|
||||||
}) {
|
}) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
return (
|
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
|
||||||
<Dropdown
|
const { showPopup } = usePopup();
|
||||||
disabled={isLoading}
|
const utils = api.useUtils();
|
||||||
items={[
|
|
||||||
...(isTemplate
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
label: t`Make template`,
|
|
||||||
action: () => openModal("CREATE_TEMPLATE"),
|
|
||||||
icon: (
|
|
||||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Edit board URL`,
|
|
||||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
|
||||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
|
|
||||||
{
|
const handleToggleFavorite = () => {
|
||||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
updateBoard.mutate({
|
||||||
action: () => openModal("DELETE_BOARD"),
|
boardPublicId,
|
||||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
favorite: !isFavorite,
|
||||||
},
|
});
|
||||||
]}
|
};
|
||||||
>
|
|
||||||
|
const updateBoard = api.board.update.useMutation({
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
void utils.board.all.invalidate();
|
||||||
|
void utils.board.byId.invalidate();
|
||||||
|
|
||||||
|
// Show popup notification
|
||||||
|
if (variables.favorite !== undefined) {
|
||||||
|
showPopup({
|
||||||
|
header: variables.favorite
|
||||||
|
? t`Added to favorites`
|
||||||
|
: t`Removed from favorites`,
|
||||||
|
message: variables.favorite
|
||||||
|
? t`${boardName ?? "Board"} has been added to your favorites.`
|
||||||
|
: t`${boardName ?? "Board"} has been removed from your favorites.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Unable to update board`,
|
||||||
|
message: t`Please try again later, or contact customer support.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
...(isTemplate && canCreateBoard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Make template`,
|
||||||
|
action: () => openModal("CREATE_TEMPLATE"),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(!isTemplate && canEditBoard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Edit board URL`,
|
||||||
|
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||||
|
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
label: isFavorite
|
||||||
|
? t`Remove from favorites`
|
||||||
|
: t`Add to favorites`,
|
||||||
|
action: handleToggleFavorite,
|
||||||
|
icon: isFavorite ? (
|
||||||
|
<HiStar className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
) : (
|
||||||
|
<HiOutlineStar className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(canDeleteBoard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||||
|
action: () => openModal("DELETE_BOARD"),
|
||||||
|
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown disabled={isLoading} items={items}>
|
||||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
HiOutlineTrash,
|
HiOutlineTrash,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
|
import { Tooltip } from "~/components/Tooltip";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
@@ -23,6 +27,7 @@ interface ListProps {
|
|||||||
interface List {
|
interface List {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
createdBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
@@ -39,8 +44,14 @@ export default function List({
|
|||||||
setSelectedPublicListId,
|
setSelectedPublicListId,
|
||||||
}: ListProps) {
|
}: ListProps) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { canCreateCard, canEditList, canDeleteList } = usePermissions();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
const isCreator = list.createdBy && session?.user.id === list.createdBy;
|
||||||
|
const canEdit = canEditList || isCreator;
|
||||||
|
const canDrag = canEditList || isCreator;
|
||||||
|
|
||||||
const openNewCardForm = (publicListId: PublicListId) => {
|
const openNewCardForm = (publicListId: PublicListId) => {
|
||||||
|
if (!canCreateCard) return;
|
||||||
openModal("NEW_CARD");
|
openModal("NEW_CARD");
|
||||||
setSelectedPublicListId(publicListId);
|
setSelectedPublicListId(publicListId);
|
||||||
};
|
};
|
||||||
@@ -59,6 +70,7 @@ export default function List({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (values: FormValues) => {
|
const onSubmit = (values: FormValues) => {
|
||||||
|
if (!canEdit) return;
|
||||||
updateList.mutate({
|
updateList.mutate({
|
||||||
listPublicId: values.listPublicId,
|
listPublicId: values.listPublicId,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
@@ -71,7 +83,12 @@ export default function List({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Draggable key={list.publicId} draggableId={list.publicId} index={index}>
|
<Draggable
|
||||||
|
key={list.publicId}
|
||||||
|
draggableId={list.publicId}
|
||||||
|
index={index}
|
||||||
|
isDragDisabled={!canDrag}
|
||||||
|
>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div
|
<div
|
||||||
key={list.publicId}
|
key={list.publicId}
|
||||||
@@ -90,41 +107,65 @@ export default function List({
|
|||||||
type="text"
|
type="text"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
onBlur={handleSubmit(onSubmit)}
|
onBlur={handleSubmit(onSubmit)}
|
||||||
|
readOnly={!canEdit}
|
||||||
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
<Tooltip
|
||||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
|
content={
|
||||||
onClick={() => openNewCardForm(list.publicId)}
|
!canCreateCard ? t`You don't have permission` : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<HiOutlinePlusSmall
|
<button
|
||||||
className="h-5 w-5 text-dark-900"
|
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 disabled:opacity-60 disabled:cursor-not-allowed dark:hover:bg-dark-200"
|
||||||
aria-hidden="true"
|
onClick={() => openNewCardForm(list.publicId)}
|
||||||
/>
|
disabled={!canCreateCard}
|
||||||
</button>
|
|
||||||
<div className="relative mr-1 inline-block">
|
|
||||||
<Dropdown
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: t`Add a card`,
|
|
||||||
action: () => openNewCardForm(list.publicId),
|
|
||||||
icon: (
|
|
||||||
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Delete list`,
|
|
||||||
action: handleOpenDeleteListConfirmation,
|
|
||||||
icon: (
|
|
||||||
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
<HiOutlinePlusSmall
|
||||||
</Dropdown>
|
className="h-5 w-5 text-dark-900"
|
||||||
</div>
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{(() => {
|
||||||
|
const dropdownItems = [
|
||||||
|
...(canCreateCard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Add a card`,
|
||||||
|
action: () => openNewCardForm(list.publicId),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(canDeleteList || isCreator
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Delete list`,
|
||||||
|
action: handleOpenDeleteListConfirmation,
|
||||||
|
icon: (
|
||||||
|
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (dropdownItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mr-1 inline-block">
|
||||||
|
<Dropdown items={dropdownItems}>
|
||||||
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { HiLink } from "react-icons/hi";
|
import { HiLink } from "react-icons/hi";
|
||||||
|
|
||||||
|
import { Tooltip } from "~/components/Tooltip";
|
||||||
|
|
||||||
const UpdateBoardSlugButton = ({
|
const UpdateBoardSlugButton = ({
|
||||||
handleOnClick,
|
handleOnClick,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
boardSlug,
|
boardSlug,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
canEdit,
|
||||||
}: {
|
}: {
|
||||||
handleOnClick: () => void;
|
handleOnClick: () => void;
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
boardSlug: string;
|
boardSlug: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
|
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
|
||||||
|
|
||||||
@@ -22,10 +27,14 @@ const UpdateBoardSlugButton = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Tooltip
|
||||||
onClick={handleOnClick}
|
content={!canEdit && !isLoading ? t`You don't have permission` : undefined}
|
||||||
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={canEdit ? handleOnClick : undefined}
|
||||||
|
disabled={!canEdit || isLoading}
|
||||||
|
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
||||||
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span>
|
<span>
|
||||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||||
@@ -41,13 +50,20 @@ const UpdateBoardSlugButton = ({
|
|||||||
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
|
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!canEdit) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
||||||
>
|
>
|
||||||
<HiLink className="h-[13px] w-[13px]" />
|
<HiLink className="h-[13px] w-[13px]" />
|
||||||
</Link>
|
</Link>
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default UpdateBoardSlugButton;
|
export default UpdateBoardSlugButton;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
|||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||||
|
import { Tooltip } from "~/components/Tooltip";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ const VisibilityButton = ({
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
|
const { canEditBoard } = usePermissions();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [stateVisibility, setStateVisibility] = useState<"public" | "private">(
|
const [stateVisibility, setStateVisibility] = useState<"public" | "private">(
|
||||||
visibility,
|
visibility,
|
||||||
@@ -60,38 +63,47 @@ const VisibilityButton = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canEdit = canEditBoard || isAdmin;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<CheckboxDropdown
|
<Tooltip
|
||||||
items={[
|
content={
|
||||||
{
|
!canEdit && !isLoading ? t`You don't have permission` : undefined
|
||||||
key: "public",
|
}
|
||||||
value: t`Public`,
|
|
||||||
selected: isPublic,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "private",
|
|
||||||
value: t`Private`,
|
|
||||||
selected: !isPublic,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
handleSelect={(_g, i) => {
|
|
||||||
setStateVisibility(isPublic ? "private" : "public");
|
|
||||||
updateBoardVisibility.mutate({
|
|
||||||
visibility: i.key as "public" | "private",
|
|
||||||
boardPublicId,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
menuSpacing="md"
|
|
||||||
>
|
>
|
||||||
<Button
|
<CheckboxDropdown
|
||||||
variant="secondary"
|
items={[
|
||||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
{
|
||||||
disabled={isLoading || !isAdmin}
|
key: "public",
|
||||||
|
value: t`Public`,
|
||||||
|
selected: isPublic,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "private",
|
||||||
|
value: t`Private`,
|
||||||
|
selected: !isPublic,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
handleSelect={(_g, i) => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
setStateVisibility(isPublic ? "private" : "public");
|
||||||
|
updateBoardVisibility.mutate({
|
||||||
|
visibility: i.key as "public" | "private",
|
||||||
|
boardPublicId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
menuSpacing="md"
|
||||||
>
|
>
|
||||||
{t`Visibility`}
|
<Button
|
||||||
</Button>
|
variant="secondary"
|
||||||
</CheckboxDropdown>
|
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||||
|
disabled={isLoading || !canEdit}
|
||||||
|
>
|
||||||
|
{t`Visibility`}
|
||||||
|
</Button>
|
||||||
|
</CheckboxDropdown>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
|
|||||||
import { Tooltip } from "~/components/Tooltip";
|
import { Tooltip } from "~/components/Tooltip";
|
||||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
@@ -63,11 +64,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
direction: "horizontal",
|
direction: "horizontal",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions();
|
||||||
|
|
||||||
const { tooltipContent: createListShortcutTooltipContent } =
|
const { tooltipContent: createListShortcutTooltipContent } =
|
||||||
useKeyboardShortcut({
|
useKeyboardShortcut({
|
||||||
type: "PRESS",
|
type: "PRESS",
|
||||||
stroke: { key: "C" },
|
stroke: { key: "C" },
|
||||||
action: () => boardId && openNewListForm(boardId),
|
action: () => boardId && canCreateList && openNewListForm(boardId),
|
||||||
description: t`Create new list`,
|
description: t`Create new list`,
|
||||||
group: "ACTIONS",
|
group: "ACTIONS",
|
||||||
});
|
});
|
||||||
@@ -260,14 +263,14 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "LIST") {
|
if (type === "LIST" && canEditList) {
|
||||||
updateListMutation.mutate({
|
updateListMutation.mutate({
|
||||||
listPublicId: draggableId,
|
listPublicId: draggableId,
|
||||||
index: destination.index,
|
index: destination.index,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "CARD") {
|
if (type === "CARD" && canEditCard) {
|
||||||
updateCardMutation.mutate({
|
updateCardMutation.mutate({
|
||||||
cardPublicId: draggableId,
|
cardPublicId: draggableId,
|
||||||
|
|
||||||
@@ -412,10 +415,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
onBlur={handleSubmit(onSubmit)}
|
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
|
||||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem]"
|
readOnly={!canEditBoard}
|
||||||
|
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem] disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
{!boardData && !isLoading && (
|
{!boardData && !isLoading && (
|
||||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||||
@@ -438,6 +443,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
workspaceSlug={workspace.slug ?? ""}
|
workspaceSlug={workspace.slug ?? ""}
|
||||||
boardSlug={boardData?.slug ?? ""}
|
boardSlug={boardData?.slug ?? ""}
|
||||||
|
canEdit={canEditBoard}
|
||||||
/>
|
/>
|
||||||
<VisibilityButton
|
<VisibilityButton
|
||||||
visibility={boardData?.visibility ?? "private"}
|
visibility={boardData?.visibility ?? "private"}
|
||||||
@@ -460,7 +466,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={createListShortcutTooltipContent}>
|
<Tooltip
|
||||||
|
content={
|
||||||
|
!canCreateList
|
||||||
|
? t`You don't have permission`
|
||||||
|
: createListShortcutTooltipContent
|
||||||
|
}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<HiOutlinePlusSmall
|
<HiOutlinePlusSmall
|
||||||
@@ -469,9 +481,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (boardId) openNewListForm(boardId);
|
if (boardId && canCreateList) openNewListForm(boardId);
|
||||||
}}
|
}}
|
||||||
disabled={!boardData}
|
disabled={!boardData || !canCreateList}
|
||||||
>
|
>
|
||||||
{t`New list`}
|
{t`New list`}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -481,6 +493,8 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
isLoading={!boardData}
|
isLoading={!boardData}
|
||||||
boardPublicId={boardId ?? ""}
|
boardPublicId={boardId ?? ""}
|
||||||
workspacePublicId={workspace.publicId}
|
workspacePublicId={workspace.publicId}
|
||||||
|
isFavorite={boardData?.favorite}
|
||||||
|
boardName={boardData?.name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -506,16 +520,25 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
{t`No lists`}
|
{t`No lists`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||||
{t`Get started by creating a new list`}
|
{canCreateList
|
||||||
|
? t`Get started by creating a new list`
|
||||||
|
: t`No lists have been created yet`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Tooltip
|
||||||
onClick={() => {
|
content={
|
||||||
if (boardId) openNewListForm(boardId);
|
!canCreateList ? t`You don't have permission` : undefined
|
||||||
}}
|
}
|
||||||
>
|
>
|
||||||
{t`Create new list`}
|
<Button
|
||||||
</Button>
|
onClick={() => {
|
||||||
|
if (boardId && canCreateList) openNewListForm(boardId);
|
||||||
|
}}
|
||||||
|
disabled={!canCreateList}
|
||||||
|
>
|
||||||
|
{t`Create new list`}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
@@ -555,6 +578,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
key={card.publicId}
|
key={card.publicId}
|
||||||
draggableId={card.publicId}
|
draggableId={card.publicId}
|
||||||
index={index}
|
index={index}
|
||||||
|
isDragDisabled={!canEditCard}
|
||||||
>
|
>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -572,13 +596,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
? `/templates/${boardId}/cards/${card.publicId}`
|
? `/templates/${boardId}/cards/${card.publicId}`
|
||||||
: `/cards/${card.publicId}`
|
: `/cards/${card.publicId}`
|
||||||
}
|
}
|
||||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
||||||
card.publicId.startsWith(
|
"PLACEHOLDER",
|
||||||
"PLACEHOLDER",
|
)
|
||||||
)
|
? "pointer-events-none"
|
||||||
? "pointer-events-none"
|
: ""
|
||||||
: ""
|
}`}
|
||||||
}`}
|
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { HiOutlineRectangleStack } from "react-icons/hi2";
|
import { HiOutlineRectangleStack, HiOutlineStar, HiStar } from "react-icons/hi2";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
|
import { Tooltip } from "~/components/Tooltip";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -11,6 +13,14 @@ import { api } from "~/utils/api";
|
|||||||
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { canCreateBoard } = usePermissions();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const updateBoard = api.board.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.board.all.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { data, isLoading } = api.board.all.useQuery(
|
const { data, isLoading } = api.board.all.useQuery(
|
||||||
{
|
{
|
||||||
@@ -20,6 +30,20 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
{ enabled: workspace.publicId ? true : false },
|
{ enabled: workspace.publicId ? true : false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleToggleFavorite = (
|
||||||
|
e: React.MouseEvent,
|
||||||
|
boardPublicId: string,
|
||||||
|
currentFavorite: boolean | undefined
|
||||||
|
) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
updateBoard.mutate({
|
||||||
|
boardPublicId,
|
||||||
|
favorite: !currentFavorite,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
||||||
@@ -41,27 +65,69 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => openModal("NEW_BOARD")}>
|
<Tooltip
|
||||||
{t`Create new ${isTemplate ? "template" : "board"}`}
|
content={
|
||||||
</Button>
|
!canCreateBoard ? t`You don't have permission` : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (canCreateBoard) openModal("NEW_BOARD");
|
||||||
|
}}
|
||||||
|
disabled={!canCreateBoard}
|
||||||
|
>
|
||||||
|
{t`Create new ${isTemplate ? "template" : "board"}`}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
<motion.div
|
||||||
|
className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3"
|
||||||
|
layout
|
||||||
|
>
|
||||||
{data?.map((board) => (
|
{data?.map((board) => (
|
||||||
<Link
|
<motion.div
|
||||||
key={board.publicId}
|
key={board.publicId}
|
||||||
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{
|
||||||
|
layout: {
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
mass: 1
|
||||||
|
},
|
||||||
|
opacity: { duration: 0.2 },
|
||||||
|
scale: { duration: 0.2 }
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
<Link
|
||||||
<PatternedBackground />
|
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
||||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
>
|
||||||
{board.name}
|
<div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||||
</p>
|
<PatternedBackground />
|
||||||
</div>
|
<button
|
||||||
</Link>
|
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
||||||
|
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
|
||||||
|
>
|
||||||
|
{board.favorite ? (
|
||||||
|
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
|
||||||
|
) : (
|
||||||
|
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||||
|
{board.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Modal from "~/components/modal";
|
|||||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { Tooltip } from "~/components/Tooltip";
|
import { Tooltip } from "~/components/Tooltip";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
@@ -17,12 +18,13 @@ import { NewBoardForm } from "./components/NewBoardForm";
|
|||||||
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const { openModal, modalContentType, isOpen } = useModal();
|
const { openModal, modalContentType, isOpen } = useModal();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
|
const { canCreateBoard } = usePermissions();
|
||||||
|
|
||||||
const { tooltipContent: createModalShortcutTooltipContent } =
|
const { tooltipContent: createModalShortcutTooltipContent } =
|
||||||
useKeyboardShortcut({
|
useKeyboardShortcut({
|
||||||
type: "PRESS",
|
type: "PRESS",
|
||||||
stroke: { key: "C" },
|
stroke: { key: "C" },
|
||||||
action: () => openModal("NEW_BOARD"),
|
action: () => canCreateBoard && openModal("NEW_BOARD"),
|
||||||
description: t`Create new ${isTemplate ? "template" : "board"}`,
|
description: t`Create new ${isTemplate ? "template" : "board"}`,
|
||||||
group: "ACTIONS",
|
group: "ACTIONS",
|
||||||
});
|
});
|
||||||
@@ -39,22 +41,40 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{!isTemplate && (
|
{!isTemplate && (
|
||||||
<Button
|
<Tooltip
|
||||||
type="button"
|
content={
|
||||||
variant="secondary"
|
!canCreateBoard ? t`You don't have permission` : undefined
|
||||||
onClick={() => openModal("IMPORT_BOARDS")}
|
|
||||||
iconLeft={
|
|
||||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t`Import`}
|
<Button
|
||||||
</Button>
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (canCreateBoard) openModal("IMPORT_BOARDS");
|
||||||
|
}}
|
||||||
|
disabled={!canCreateBoard}
|
||||||
|
iconLeft={
|
||||||
|
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t`Import`}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={createModalShortcutTooltipContent}>
|
<Tooltip
|
||||||
|
content={
|
||||||
|
!canCreateBoard
|
||||||
|
? t`You don't have permission`
|
||||||
|
: createModalShortcutTooltipContent
|
||||||
|
}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => openModal("NEW_BOARD")}
|
onClick={() => {
|
||||||
|
if (canCreateBoard) openModal("NEW_BOARD");
|
||||||
|
}}
|
||||||
|
disabled={!canCreateBoard}
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,12 +36,21 @@ const truncate = (value: string | null, maxLength = 50) => {
|
|||||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getUserDisplayName = (
|
||||||
|
user: { name?: string | null; email?: string | null } | null | undefined,
|
||||||
|
): string => {
|
||||||
|
if (user?.name?.trim()) return user.name;
|
||||||
|
if (user?.email) return user.email;
|
||||||
|
return t`Member`;
|
||||||
|
};
|
||||||
|
|
||||||
const getActivityText = ({
|
const getActivityText = ({
|
||||||
type,
|
type,
|
||||||
toTitle,
|
toTitle,
|
||||||
fromList,
|
fromList,
|
||||||
toList,
|
toList,
|
||||||
memberName,
|
memberName,
|
||||||
|
memberEmail,
|
||||||
isSelf,
|
isSelf,
|
||||||
label,
|
label,
|
||||||
fromTitle,
|
fromTitle,
|
||||||
@@ -54,6 +63,7 @@ const getActivityText = ({
|
|||||||
fromList: string | null;
|
fromList: string | null;
|
||||||
toList: string | null;
|
toList: string | null;
|
||||||
memberName: string | null;
|
memberName: string | null;
|
||||||
|
memberEmail: string | null;
|
||||||
isSelf: boolean;
|
isSelf: boolean;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
fromTitle?: string | null;
|
fromTitle?: string | null;
|
||||||
@@ -62,6 +72,7 @@ const getActivityText = ({
|
|||||||
dateLocale: DateFnsLocale;
|
dateLocale: DateFnsLocale;
|
||||||
mergedLabels?: string[];
|
mergedLabels?: string[];
|
||||||
}) => {
|
}) => {
|
||||||
|
const displayName = memberName ?? memberEmail ?? t`Member`;
|
||||||
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
||||||
<span className="font-medium text-light-1000 dark:text-dark-1000">
|
<span className="font-medium text-light-1000 dark:text-dark-1000">
|
||||||
{children}
|
{children}
|
||||||
@@ -139,23 +150,23 @@ const getActivityText = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "card.updated.member.added" && memberName) {
|
if (type === "card.updated.member.added" && displayName) {
|
||||||
if (isSelf) return <Trans>self-assigned the card</Trans>;
|
if (isSelf) return <Trans>self-assigned the card</Trans>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Trans>
|
<Trans>
|
||||||
assigned <TextHighlight>{truncate(memberName)}</TextHighlight> to the
|
assigned <TextHighlight>{truncate(displayName)}</TextHighlight> to the
|
||||||
card
|
card
|
||||||
</Trans>
|
</Trans>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "card.updated.member.removed" && memberName) {
|
if (type === "card.updated.member.removed" && displayName) {
|
||||||
if (isSelf) return <Trans>unassigned themselves from the card</Trans>;
|
if (isSelf) return <Trans>unassigned themselves from the card</Trans>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Trans>
|
<Trans>
|
||||||
unassigned <TextHighlight>{truncate(memberName)}</TextHighlight> from
|
unassigned <TextHighlight>{truncate(displayName)}</TextHighlight> from
|
||||||
the card
|
the card
|
||||||
</Trans>
|
</Trans>
|
||||||
);
|
);
|
||||||
@@ -352,7 +363,7 @@ const ActivityList = ({
|
|||||||
limit: ACTIVITIES_PAGE_SIZE,
|
limit: ACTIVITIES_PAGE_SIZE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!cardPublicId,
|
enabled: !!cardPublicId && cardPublicId.length >= 12,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -455,6 +466,7 @@ const ActivityList = ({
|
|||||||
fromList: activity.fromList?.name ?? null,
|
fromList: activity.fromList?.name ?? null,
|
||||||
toList: activity.toList?.name ?? null,
|
toList: activity.toList?.name ?? null,
|
||||||
memberName: activity.member?.user?.name ?? null,
|
memberName: activity.member?.user?.name ?? null,
|
||||||
|
memberEmail: activity.member?.user?.email ?? null,
|
||||||
isSelf: activity.member?.user?.id === sessionData?.user.id,
|
isSelf: activity.member?.user?.id === sessionData?.user.id,
|
||||||
label: activity.label?.name ?? null,
|
label: activity.label?.name ?? null,
|
||||||
fromTitle: activity.fromTitle ?? null,
|
fromTitle: activity.fromTitle ?? null,
|
||||||
@@ -508,7 +520,7 @@ const ActivityList = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
|
<span className="font-medium dark:text-dark-1000">{`${getUserDisplayName(activity.user)} `}</span>
|
||||||
<span className="space-x-1 text-light-900 dark:text-dark-800">
|
<span className="space-x-1 text-light-900 dark:text-dark-800">
|
||||||
{activityText}
|
{activityText}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||||
|
|
||||||
@@ -18,62 +19,33 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
|
const uploadFile = async (file: File) => {
|
||||||
const confirmAttachment = api.attachment.confirm.useMutation({
|
setUploading(true);
|
||||||
onSuccess: async () => {
|
|
||||||
|
try {
|
||||||
|
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/api/upload/attachment?cardPublicId=${encodeURIComponent(cardPublicId)}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": file.type,
|
||||||
|
"x-original-filename": file.name,
|
||||||
|
},
|
||||||
|
body: file,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Upload failed");
|
||||||
|
}
|
||||||
|
|
||||||
await invalidateCard(utils, cardPublicId);
|
await invalidateCard(utils, cardPublicId);
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Attachment uploaded`,
|
header: t`Attachment uploaded`,
|
||||||
message: t`Your file has been uploaded successfully.`,
|
message: t`Your file has been uploaded successfully.`,
|
||||||
icon: "success",
|
icon: "success",
|
||||||
});
|
});
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
showPopup({
|
|
||||||
header: t`Upload failed`,
|
|
||||||
message: t`Failed to upload attachment. Please try again.`,
|
|
||||||
icon: "error",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
setUploading(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadFile = async (file: File) => {
|
|
||||||
setUploading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Generate presigned URL
|
|
||||||
const { url, key } = await generateUploadUrl.mutateAsync({
|
|
||||||
cardPublicId,
|
|
||||||
filename: file.name,
|
|
||||||
contentType: file.type,
|
|
||||||
size: file.size,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Upload file to S3
|
|
||||||
const uploadResponse = await fetch(url, {
|
|
||||||
method: "PUT",
|
|
||||||
body: file,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": file.type,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!uploadResponse.ok) {
|
|
||||||
throw new Error("Upload failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm attachment in database
|
|
||||||
await confirmAttachment.mutateAsync({
|
|
||||||
cardPublicId,
|
|
||||||
s3Key: key,
|
|
||||||
filename: file.name,
|
|
||||||
originalFilename: file.name,
|
|
||||||
contentType: file.type,
|
|
||||||
size: file.size,
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Upload failed`,
|
header: t`Upload failed`,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
|
|||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -49,6 +50,7 @@ const Comment = ({
|
|||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { canEditComment, canDeleteComment } = usePermissions();
|
||||||
const { handleSubmit, setValue, watch } = useForm<FormValues>({
|
const { handleSubmit, setValue, watch } = useForm<FormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
comment,
|
comment,
|
||||||
@@ -80,7 +82,7 @@ const Comment = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dropdownItems = [
|
const dropdownItems = [
|
||||||
...(isAuthor
|
...(isAuthor && canEditComment
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: t`Edit comment`,
|
label: t`Edit comment`,
|
||||||
@@ -89,7 +91,7 @@ const Comment = ({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(isAuthor || isAdmin
|
...((isAuthor || canDeleteComment)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: t`Delete comment`,
|
label: t`Delete comment`,
|
||||||
|
|||||||
@@ -5,29 +5,53 @@ import {
|
|||||||
HiOutlineTrash,
|
HiOutlineTrash,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
|
|
||||||
export default function BoardDropdown() {
|
export default function CardDropdown({
|
||||||
|
cardCreatedBy,
|
||||||
|
}: {
|
||||||
|
cardCreatedBy?: string | null;
|
||||||
|
}) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { canEditCard, canDeleteCard } = usePermissions();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
...(canEditCard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Add checklist`,
|
||||||
|
action: () => openModal("ADD_CHECKLIST"),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(canDeleteCard || isCreator
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Delete card`,
|
||||||
|
action: () => openModal("DELETE_CARD"),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown items={items}>
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: t`Add checklist`,
|
|
||||||
action: () => openModal("ADD_CHECKLIST"),
|
|
||||||
icon: (
|
|
||||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Delete card`,
|
|
||||||
action: () => openModal("DELETE_CARD"),
|
|
||||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ interface DueDateSelectorProps {
|
|||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
dueDate: Date | null | undefined;
|
dueDate: Date | null | undefined;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DueDateSelector({
|
export function DueDateSelector({
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
dueDate,
|
dueDate,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
disabled = false,
|
||||||
}: DueDateSelectorProps) {
|
}: DueDateSelectorProps) {
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -105,9 +107,9 @@ export function DueDateSelector({
|
|||||||
<div className="relative flex w-full items-center text-left">
|
<div className="relative flex w-full items-center text-left">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
disabled={isLoading}
|
disabled={isLoading || disabled}
|
||||||
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100"
|
className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}
|
||||||
>
|
>
|
||||||
{dueDate ? (
|
{dueDate ? (
|
||||||
<span>{format(dueDate, "MMM d, yyyy")}</span>
|
<span>{format(dueDate, "MMM d, yyyy")}</span>
|
||||||
@@ -118,7 +120,7 @@ export function DueDateSelector({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && !disabled && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
|
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ interface LabelSelectorProps {
|
|||||||
leftIcon: React.ReactNode;
|
leftIcon: React.ReactNode;
|
||||||
}[];
|
}[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LabelSelector({
|
export default function LabelSelector({
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
labels,
|
labels,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
disabled = false,
|
||||||
}: LabelSelectorProps) {
|
}: LabelSelectorProps) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
@@ -93,9 +95,10 @@ export default function LabelSelector({
|
|||||||
handleSelect={(_, label) => {
|
handleSelect={(_, label) => {
|
||||||
addOrRemoveLabel.mutate({ cardPublicId, labelPublicId: label.key });
|
addOrRemoveLabel.mutate({ cardPublicId, labelPublicId: label.key });
|
||||||
}}
|
}}
|
||||||
handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
|
handleEdit={disabled ? undefined : (labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
|
||||||
handleCreate={() => openModal("NEW_LABEL")}
|
handleCreate={disabled ? undefined : () => openModal("NEW_LABEL")}
|
||||||
createNewItemLabel={t`Create new label`}
|
createNewItemLabel={t`Create new label`}
|
||||||
|
disabled={disabled}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
{selectedLabels.length ? (
|
{selectedLabels.length ? (
|
||||||
@@ -110,7 +113,7 @@ export default function LabelSelector({
|
|||||||
<Badge value={t`Add label`} iconLeft={<HiMiniPlus size={14} />} />
|
<Badge value={t`Add label`} iconLeft={<HiMiniPlus size={14} />} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
|
||||||
<HiMiniPlus size={22} className="pr-2" />
|
<HiMiniPlus size={22} className="pr-2" />
|
||||||
{t`Add label`}
|
{t`Add label`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ interface ListSelectorProps {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
}[];
|
}[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ListSelector({
|
export default function ListSelector({
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
lists,
|
lists,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
disabled = false,
|
||||||
}: ListSelectorProps) {
|
}: ListSelectorProps) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
@@ -77,9 +79,10 @@ export default function ListSelector({
|
|||||||
index: 0,
|
index: 0,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
disabled={disabled}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
|
||||||
{selectedList?.value}
|
{selectedList?.value}
|
||||||
</div>
|
</div>
|
||||||
</CheckboxDropdown>
|
</CheckboxDropdown>
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ interface MemberSelectorProps {
|
|||||||
imageUrl: string | undefined;
|
imageUrl: string | undefined;
|
||||||
}[];
|
}[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MemberSelector({
|
export default function MemberSelector({
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
members,
|
members,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
disabled = false,
|
||||||
}: MemberSelectorProps) {
|
}: MemberSelectorProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -108,11 +110,12 @@ export default function MemberSelector({
|
|||||||
workspaceMemberPublicId: member.key,
|
workspaceMemberPublicId: member.key,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
handleCreate={handleInviteMember}
|
handleCreate={disabled ? undefined : handleInviteMember}
|
||||||
createNewItemLabel={t`Invite member`}
|
createNewItemLabel={t`Invite member`}
|
||||||
|
disabled={disabled}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
|
||||||
{selectedMembers.length ? (
|
{selectedMembers.length ? (
|
||||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||||
{selectedMembers.map(({ value, imageUrl }) => (
|
{selectedMembers.map(({ value, imageUrl }) => (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { HiOutlineArrowUp } from "react-icons/hi2";
|
import { HiOutlineArrowUp } from "react-icons/hi2";
|
||||||
|
|
||||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||||
@@ -15,6 +16,7 @@ interface FormValues {
|
|||||||
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
|
const { canCreateComment } = usePermissions();
|
||||||
const { handleSubmit, setValue, watch, reset } = useForm<FormValues>({
|
const { handleSubmit, setValue, watch, reset } = useForm<FormValues>({
|
||||||
values: {
|
values: {
|
||||||
comment: "",
|
comment: "",
|
||||||
@@ -46,6 +48,10 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!canCreateComment) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import Modal from "~/components/modal";
|
|||||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
@@ -44,13 +47,19 @@ interface FormValues {
|
|||||||
|
|
||||||
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { canEditCard } = usePermissions();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
const cardId = Array.isArray(router.query.cardId)
|
const cardId = Array.isArray(router.query.cardId)
|
||||||
? router.query.cardId[0]
|
? router.query.cardId[0]
|
||||||
: router.query.cardId;
|
: router.query.cardId;
|
||||||
|
|
||||||
const { data: card } = api.card.byId.useQuery({
|
const { data: card } = api.card.byId.useQuery(
|
||||||
cardPublicId: cardId ?? "",
|
{ cardPublicId: cardId ?? "" },
|
||||||
});
|
{ enabled: !!cardId && cardId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||||
|
const canEdit = canEditCard || isCreator;
|
||||||
|
|
||||||
const board = card?.list.board;
|
const board = card?.list.board;
|
||||||
const labels = board?.labels;
|
const labels = board?.labels;
|
||||||
@@ -116,6 +125,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
cardPublicId={cardId ?? ""}
|
cardPublicId={cardId ?? ""}
|
||||||
lists={formattedLists}
|
lists={formattedLists}
|
||||||
isLoading={!card}
|
isLoading={!card}
|
||||||
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 flex w-full flex-row">
|
<div className="mb-4 flex w-full flex-row">
|
||||||
@@ -124,6 +134,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
cardPublicId={cardId ?? ""}
|
cardPublicId={cardId ?? ""}
|
||||||
labels={formattedLabels}
|
labels={formattedLabels}
|
||||||
isLoading={!card}
|
isLoading={!card}
|
||||||
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isTemplate && (
|
{!isTemplate && (
|
||||||
@@ -133,6 +144,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
cardPublicId={cardId ?? ""}
|
cardPublicId={cardId ?? ""}
|
||||||
members={formattedMembers}
|
members={formattedMembers}
|
||||||
isLoading={!card}
|
isLoading={!card}
|
||||||
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -142,6 +154,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
cardPublicId={cardId ?? ""}
|
cardPublicId={cardId ?? ""}
|
||||||
dueDate={card?.dueDate}
|
dueDate={card?.dueDate}
|
||||||
isLoading={!card}
|
isLoading={!card}
|
||||||
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,6 +175,8 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
} = useModal();
|
} = useModal();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
|
const { canEditCard } = usePermissions();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
|
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -170,9 +185,13 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
? router.query.cardId[0]
|
? router.query.cardId[0]
|
||||||
: router.query.cardId;
|
: router.query.cardId;
|
||||||
|
|
||||||
const { data: card, isLoading } = api.card.byId.useQuery({
|
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||||
cardPublicId: cardId ?? "",
|
{ cardPublicId: cardId ?? "" },
|
||||||
});
|
{ enabled: !!cardId && cardId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||||
|
const canEdit = canEditCard || isCreator;
|
||||||
|
|
||||||
const refetchCard = async () => {
|
const refetchCard = async () => {
|
||||||
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
|
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
|
||||||
@@ -298,7 +317,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Dropdown />
|
<Dropdown cardCreatedBy={card?.createdBy} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -326,9 +345,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
<textarea
|
<textarea
|
||||||
id="title"
|
id="title"
|
||||||
{...register("title")}
|
{...register("title")}
|
||||||
onBlur={handleSubmit(onSubmit)}
|
onBlur={canEdit ? handleSubmit(onSubmit) : undefined}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
|
disabled={!canEdit}
|
||||||
|
className={`block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem] ${!canEdit ? "cursor-default" : ""}`}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const target = e.target as HTMLTextAreaElement;
|
const target = e.target as HTMLTextAreaElement;
|
||||||
target.style.height = "auto";
|
target.style.height = "auto";
|
||||||
@@ -354,9 +374,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Editor
|
<Editor
|
||||||
content={card.description}
|
content={card.description}
|
||||||
onChange={(e) => setValue("description", e)}
|
onChange={canEdit ? (e) => setValue("description", e) : undefined}
|
||||||
onBlur={() => handleSubmit(onSubmit)()}
|
onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined}
|
||||||
workspaceMembers={board?.workspace.members ?? []}
|
workspaceMembers={board?.workspace.members ?? []}
|
||||||
|
readOnly={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -366,6 +387,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
cardPublicId={cardId}
|
cardPublicId={cardId}
|
||||||
activeChecklistForm={activeChecklistForm}
|
activeChecklistForm={activeChecklistForm}
|
||||||
setActiveChecklistForm={setActiveChecklistForm}
|
setActiveChecklistForm={setActiveChecklistForm}
|
||||||
|
viewOnly={!canEdit}
|
||||||
/>
|
/>
|
||||||
{!isTemplate && (
|
{!isTemplate && (
|
||||||
<>
|
<>
|
||||||
@@ -374,12 +396,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
<AttachmentThumbnails
|
<AttachmentThumbnails
|
||||||
attachments={card.attachments}
|
attachments={card.attachments}
|
||||||
cardPublicId={cardId ?? ""}
|
cardPublicId={cardId ?? ""}
|
||||||
|
isReadOnly={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-6">
|
{canEdit && (
|
||||||
<AttachmentUpload cardPublicId={cardId} />
|
<div className="mt-6">
|
||||||
</div>
|
<AttachmentUpload cardPublicId={cardId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { HiXMark } from "react-icons/hi2";
|
||||||
|
|
||||||
|
import type { Permission } from "@kan/shared";
|
||||||
|
import { permissionCategories } from "@kan/shared";
|
||||||
|
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import Toggle from "~/components/Toggle";
|
||||||
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
export function EditMemberPermissionsModal() {
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
|
const { modalContentType, entityId, entityLabel, closeModal } = useModal();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { data, isLoading } = api.permission.getMemberPermissions.useQuery(
|
||||||
|
{
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
memberPublicId: entityId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled:
|
||||||
|
modalContentType === "EDIT_MEMBER_PERMISSIONS" && !!entityId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const grantMutation = api.permission.grantPermission.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Permissions updated`,
|
||||||
|
message: t`The member's permissions have been updated.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Unable to update permissions`,
|
||||||
|
message: t`Please try again later, or contact customer support.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
await utils.permission.getMemberPermissions.invalidate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
memberPublicId: entityId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const revokeMutation = api.permission.revokePermission.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Permissions updated`,
|
||||||
|
message: t`The member's permissions have been updated.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Unable to update permissions`,
|
||||||
|
message: t`Please try again later, or contact customer support.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
await utils.permission.getMemberPermissions.invalidate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
memberPublicId: entityId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetMutation = api.permission.resetMemberPermissions.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Permissions reset`,
|
||||||
|
message: t`This member's permissions have been reset to their role defaults.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.permission.getMemberPermissions.invalidate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
memberPublicId: entityId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Unable to reset permissions`,
|
||||||
|
message: t`Please try again later, or contact customer support.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectivePermissions = (data?.permissions ?? []) as Permission[];
|
||||||
|
const hasOverrides = (data?.overrides?.length ?? 0) > 0;
|
||||||
|
const isBusy =
|
||||||
|
grantMutation.isPending ||
|
||||||
|
revokeMutation.isPending ||
|
||||||
|
resetMutation.isPending;
|
||||||
|
|
||||||
|
const handleToggle = (permission: Permission, nextState: boolean) => {
|
||||||
|
if (!workspace.publicId || !entityId) return;
|
||||||
|
|
||||||
|
if (nextState) {
|
||||||
|
grantMutation.mutate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
memberPublicId: entityId,
|
||||||
|
permission,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
revokeMutation.mutate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
memberPublicId: entityId,
|
||||||
|
permission,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const permissionLabels: Record<Permission, string> = {
|
||||||
|
"workspace:view": t`Can view workspace`,
|
||||||
|
"workspace:edit": t`Can edit workspace`,
|
||||||
|
"workspace:delete": t`Can delete workspace`,
|
||||||
|
"workspace:manage": t`Can manage workspace settings`,
|
||||||
|
|
||||||
|
"board:view": t`Can view boards`,
|
||||||
|
"board:create": t`Can create boards`,
|
||||||
|
"board:edit": t`Can edit boards`,
|
||||||
|
"board:delete": t`Can delete boards`,
|
||||||
|
|
||||||
|
"list:view": t`Can view lists`,
|
||||||
|
"list:create": t`Can create lists`,
|
||||||
|
"list:edit": t`Can edit lists`,
|
||||||
|
"list:delete": t`Can delete lists`,
|
||||||
|
|
||||||
|
"card:view": t`Can view cards`,
|
||||||
|
"card:create": t`Can create cards`,
|
||||||
|
"card:edit": t`Can edit cards`,
|
||||||
|
"card:delete": t`Can delete cards`,
|
||||||
|
|
||||||
|
"comment:view": t`Can view comments`,
|
||||||
|
"comment:create": t`Can add comments`,
|
||||||
|
"comment:edit": t`Can edit comments`,
|
||||||
|
"comment:delete": t`Can delete comments`,
|
||||||
|
|
||||||
|
"member:view": t`Can view members`,
|
||||||
|
"member:invite": t`Can invite members`,
|
||||||
|
"member:edit": t`Can edit member roles and permissions`,
|
||||||
|
"member:remove": t`Can remove members`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-md bg-light-50 text-light-1000 dark:bg-dark-100 dark:text-dark-1000">
|
||||||
|
<div className="px-5 pt-5">
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-1 text-sm font-semibold">
|
||||||
|
{t`Edit permissions`}
|
||||||
|
</h2>
|
||||||
|
<p className="min-h-[16px] text-xs text-light-900 dark:text-dark-900">
|
||||||
|
{entityLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="ml-2 inline-flex h-6 w-6 items-center justify-center rounded-md text-light-900 hover:bg-light-200 focus:outline-none dark:text-dark-900 dark:hover:bg-dark-200"
|
||||||
|
aria-label={t`Close`}
|
||||||
|
>
|
||||||
|
<HiXMark className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-xs text-light-900 dark:text-dark-900">
|
||||||
|
{t`Loading permissions...`}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-80 pb-4 space-y-3 overflow-y-auto pr-1">
|
||||||
|
{Object.values(permissionCategories).map((category, index) => (
|
||||||
|
<div
|
||||||
|
key={category.label}
|
||||||
|
className={`py-2 ${
|
||||||
|
index > 0
|
||||||
|
? "border-t border-light-300 dark:border-dark-300"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="my-2 text-[12px] font-semibold text-light-900 dark:text-dark-950">
|
||||||
|
{category.label}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{category.permissions.map((permission) => {
|
||||||
|
const label =
|
||||||
|
permissionLabels[permission] ?? (permission as string);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={permission}
|
||||||
|
className="flex items-center justify-between gap-3 py-0.5"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-light-900 dark:text-dark-900">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<Toggle
|
||||||
|
label={label}
|
||||||
|
showLabel={false}
|
||||||
|
isChecked={effectivePermissions.includes(permission)}
|
||||||
|
disabled={isBusy}
|
||||||
|
onChange={() =>
|
||||||
|
handleToggle(
|
||||||
|
permission,
|
||||||
|
!effectivePermissions.includes(permission),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (!workspace.publicId || !entityId || isBusy) return;
|
||||||
|
resetMutation.mutate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
memberPublicId: entityId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isBusy || !hasOverrides}
|
||||||
|
isLoading={resetMutation.isPending}
|
||||||
|
>
|
||||||
|
{t`Reset to role defaults`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
|
|||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import {
|
import {
|
||||||
HiBolt,
|
HiBolt,
|
||||||
|
HiChevronDown,
|
||||||
HiEllipsisHorizontal,
|
HiEllipsisHorizontal,
|
||||||
HiOutlinePlusSmall,
|
HiOutlinePlusSmall,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
@@ -19,24 +20,55 @@ import FeedbackModal from "~/components/FeedbackModal";
|
|||||||
import Modal from "~/components/modal";
|
import Modal from "~/components/modal";
|
||||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
|
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
|
||||||
import { InviteMemberForm } from "./components/InviteMemberForm";
|
import { InviteMemberForm } from "./components/InviteMemberForm";
|
||||||
|
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
|
||||||
|
|
||||||
export default function MembersPage() {
|
export default function MembersPage() {
|
||||||
const { modalContentType, openModal, isOpen } = useModal();
|
const { modalContentType, openModal, isOpen } = useModal();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
const { data, isLoading } = api.workspace.byId.useQuery(
|
const { data, isLoading } = api.workspace.byId.useQuery(
|
||||||
{ workspacePublicId: workspace.publicId },
|
{ workspacePublicId: workspace.publicId },
|
||||||
// { enabled: workspace?.publicId ? true : false },
|
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
|
const { canEditMember } = usePermissions();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const updateRoleMutation = api.member.updateRole.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
if (workspace.publicId && workspace.publicId.length >= 12) {
|
||||||
|
await utils.workspace.byId.invalidate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showPopup({
|
||||||
|
header: t`Role updated`,
|
||||||
|
message: t`The member's role has been updated.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Unable to update role`,
|
||||||
|
message: t`Please try again later, or contact customer support.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const subscriptions = data?.subscriptions as Subscription[] | undefined;
|
const subscriptions = data?.subscriptions as Subscription[] | undefined;
|
||||||
|
|
||||||
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
||||||
@@ -67,6 +99,16 @@ export default function MembersPage() {
|
|||||||
showSkeleton?: boolean;
|
showSkeleton?: boolean;
|
||||||
showPendingIcon?: boolean;
|
showPendingIcon?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const handleRoleChange = (newRole: "admin" | "member" | "guest") => {
|
||||||
|
if (!memberPublicId) return;
|
||||||
|
|
||||||
|
updateRoleMutation.mutate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
memberPublicId,
|
||||||
|
role: newRole,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="rounded-b-lg">
|
<tr className="rounded-b-lg">
|
||||||
<td
|
<td
|
||||||
@@ -84,7 +126,6 @@ export default function MembersPage() {
|
|||||||
name={memberName ?? ""}
|
name={memberName ?? ""}
|
||||||
email={memberEmail ?? ""}
|
email={memberEmail ?? ""}
|
||||||
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
|
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
|
||||||
icon={showPendingIcon ? "?" : undefined}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -127,32 +168,67 @@ export default function MembersPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between px-2 sm:px-3">
|
<div className="flex w-full items-center justify-between px-2 sm:px-3">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
{showSkeleton ? (
|
||||||
className={twMerge(
|
<span
|
||||||
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
|
className={twMerge(
|
||||||
showSkeleton &&
|
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
|
||||||
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
|
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
{memberRole &&
|
) : (
|
||||||
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
|
<div className="relative inline-flex items-center">
|
||||||
</span>
|
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]">
|
||||||
|
{memberRole &&
|
||||||
|
memberRole.charAt(0).toUpperCase() +
|
||||||
|
memberRole.slice(1)}
|
||||||
|
{canEditMember && session?.user.id !== memberId && (
|
||||||
|
<HiChevronDown className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{canEditMember && session?.user.id !== memberId && (
|
||||||
|
<select
|
||||||
|
value={memberRole}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleRoleChange(
|
||||||
|
e.target.value as "admin" | "member" | "guest",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={updateRoleMutation.isPending}
|
||||||
|
className="absolute inset-0 h-full w-full cursor-pointer appearance-none border-none bg-transparent p-0 text-[10px] leading-none opacity-0 focus:outline-none focus-visible:outline-none sm:text-[11px]"
|
||||||
|
>
|
||||||
|
<option value="admin">{t`Admin`}</option>
|
||||||
|
<option value="member">{t`Member`}</option>
|
||||||
|
<option value="guest">{t`Guest`}</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(memberStatus === "invited" || memberStatus === "paused") && (
|
{(memberStatus === "invited" || memberStatus === "paused") && (
|
||||||
<span className="mt-1 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:ml-2 sm:mt-0 sm:text-[11px]">
|
<span className="inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:text-[11px]">
|
||||||
{memberStatus === "invited" ? t`Pending` : t`Paused`}
|
{memberStatus === "invited" ? t`Pending` : t`Paused`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"relative z-50",
|
"relative",
|
||||||
(workspace.role !== "admin" || showSkeleton) && "hidden",
|
(workspace.role !== "admin" || showSkeleton) && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{session?.user.id !== memberId && (
|
{session?.user.id !== memberId && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={[
|
items={[
|
||||||
|
{
|
||||||
|
label: t`Edit permissions`,
|
||||||
|
action: () =>
|
||||||
|
openModal(
|
||||||
|
"EDIT_MEMBER_PERMISSIONS",
|
||||||
|
memberPublicId,
|
||||||
|
memberEmail ?? "",
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t`Remove member`,
|
label: t`Remove member`,
|
||||||
action: () =>
|
action: () =>
|
||||||
@@ -166,7 +242,7 @@ export default function MembersPage() {
|
|||||||
>
|
>
|
||||||
<HiEllipsisHorizontal
|
<HiEllipsisHorizontal
|
||||||
size={20}
|
size={20}
|
||||||
className="text-light-900 dark:text-dark-900 sm:size-[25px]"
|
className="text-light-900 dark:text-dark-900 sm:size-[20px]"
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
)}
|
)}
|
||||||
@@ -321,6 +397,14 @@ export default function MembersPage() {
|
|||||||
>
|
>
|
||||||
<DeleteMemberConfirmation />
|
<DeleteMemberConfirmation />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
modalSize="sm"
|
||||||
|
isVisible={isOpen && modalContentType === "EDIT_MEMBER_PERMISSIONS"}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<EditMemberPermissionsModal />
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function CardModal({
|
|||||||
cardPublicId: cardPublicId ?? "",
|
cardPublicId: cardPublicId ?? "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: isOpen && !!cardPublicId,
|
enabled: isOpen && !!cardPublicId && cardPublicId.length >= 12,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|||||||
103
apps/web/src/views/settings/PermissionsSettings.tsx
Normal file
103
apps/web/src/views/settings/PermissionsSettings.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import Modal from "~/components/modal";
|
||||||
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
import { ClearCustomPermissionsConfirmation } from "./components/ClearCustomPermissionsConfirmation";
|
||||||
|
import { RolePermissions } from "./components/RolePermissions";
|
||||||
|
|
||||||
|
export default function PermissionsSettings() {
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
|
const { openModal, isOpen, modalContentType } = useModal();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const isAdmin = workspace.role === "admin";
|
||||||
|
|
||||||
|
const resetAllOverrides = api.permission.resetWorkspaceMemberPermissions.useMutation(
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Overrides cleared`,
|
||||||
|
message: t`All member permission overrides have been reset to their role defaults.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh any relevant workspace data
|
||||||
|
if (workspace.publicId && workspace.publicId.length >= 12) {
|
||||||
|
await utils.workspace.byId.invalidate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Unable to clear overrides`,
|
||||||
|
message: t`Please try again later, or contact customer support.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead title={t`Settings | Permissions`} />
|
||||||
|
|
||||||
|
<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`Workspace permissions`}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-6 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
|
{t`Configure which actions are allowed for each workspace role. These permissions apply to all members with that role.`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isAdmin ? (
|
||||||
|
<>
|
||||||
|
<RolePermissions />
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="mb-4 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||||
|
{t`Custom permissions`}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-6 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
|
{t`Clear any custom member permissions so that all members only inherit permissions from their role defaults.`}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (!workspace.publicId || resetAllOverrides.isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openModal("CLEAR_CUSTOM_PERMISSIONS");
|
||||||
|
}}
|
||||||
|
disabled={resetAllOverrides.isPending}
|
||||||
|
>
|
||||||
|
{t`Clear custom permissions`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
|
{t`You need to be an admin to manage workspace permissions.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
modalSize="sm"
|
||||||
|
isVisible={isOpen && modalContentType === "CLEAR_CUSTOM_PERMISSIONS"}
|
||||||
|
>
|
||||||
|
<ClearCustomPermissionsConfirmation
|
||||||
|
resetAllOverrides={resetAllOverrides}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import FeedbackModal from "~/components/FeedbackModal";
|
|||||||
import Modal from "~/components/modal";
|
import Modal from "~/components/modal";
|
||||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -25,13 +26,15 @@ import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation"
|
|||||||
export default function WorkspaceSettings() {
|
export default function WorkspaceSettings() {
|
||||||
const { modalContentType, openModal, isOpen } = useModal();
|
const { modalContentType, openModal, isOpen } = useModal();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
|
const { canEditWorkspace } = usePermissions();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data } = api.user.getUser.useQuery();
|
const { data } = api.user.getUser.useQuery();
|
||||||
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
||||||
|
|
||||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||||
workspacePublicId: workspace.publicId,
|
{ workspacePublicId: workspace.publicId },
|
||||||
});
|
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
const subscriptions = workspaceData?.subscriptions as
|
const subscriptions = workspaceData?.subscriptions as
|
||||||
| Subscription[]
|
| Subscription[]
|
||||||
@@ -61,6 +64,7 @@ export default function WorkspaceSettings() {
|
|||||||
<UpdateWorkspaceNameForm
|
<UpdateWorkspaceNameForm
|
||||||
workspacePublicId={workspace.publicId}
|
workspacePublicId={workspace.publicId}
|
||||||
workspaceName={workspace.name}
|
workspaceName={workspace.name}
|
||||||
|
disabled={!canEditWorkspace}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||||
@@ -70,6 +74,7 @@ export default function WorkspaceSettings() {
|
|||||||
workspacePublicId={workspace.publicId}
|
workspacePublicId={workspace.publicId}
|
||||||
workspaceUrl={workspace.slug ?? ""}
|
workspaceUrl={workspace.slug ?? ""}
|
||||||
workspacePlan={workspace.plan ?? "free"}
|
workspacePlan={workspace.plan ?? "free"}
|
||||||
|
disabled={!canEditWorkspace}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||||
@@ -78,6 +83,7 @@ export default function WorkspaceSettings() {
|
|||||||
<UpdateWorkspaceDescriptionForm
|
<UpdateWorkspaceDescriptionForm
|
||||||
workspacePublicId={workspace.publicId}
|
workspacePublicId={workspace.publicId}
|
||||||
workspaceDescription={workspace.description ?? ""}
|
workspaceDescription={workspace.description ?? ""}
|
||||||
|
disabled={!canEditWorkspace}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||||
@@ -85,7 +91,10 @@ export default function WorkspaceSettings() {
|
|||||||
</h2>
|
</h2>
|
||||||
<UpdateWorkspaceEmailVisibilityForm
|
<UpdateWorkspaceEmailVisibilityForm
|
||||||
workspacePublicId={workspace.publicId}
|
workspacePublicId={workspace.publicId}
|
||||||
showEmailsToMembers={workspaceData?.showEmailsToMembers ?? false}
|
showEmailsToMembers={Boolean(
|
||||||
|
workspaceData?.showEmailsToMembers ?? false,
|
||||||
|
)}
|
||||||
|
disabled={!canEditWorkspace}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||||
|
|||||||
@@ -58,28 +58,6 @@ export default function Avatar({
|
|||||||
const [crop, setCrop] = useState<PercentCrop>();
|
const [crop, setCrop] = useState<PercentCrop>();
|
||||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
const updateUser = api.user.update.useMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
showPopup({
|
|
||||||
header: t`Profile image updated`,
|
|
||||||
message: t`Your profile image has been updated.`,
|
|
||||||
icon: "success",
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await utils.user.getUser.refetch();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
showPopup({
|
|
||||||
header: t`Error updating profile image`,
|
|
||||||
message: t`Please try again later, or contact customer support.`,
|
|
||||||
icon: "error",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined;
|
const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined;
|
||||||
|
|
||||||
@@ -187,29 +165,32 @@ export default function Avatar({
|
|||||||
const originalExt = selectedFile.name.split(".").pop() ?? "jpg";
|
const originalExt = selectedFile.name.split(".").pop() ?? "jpg";
|
||||||
const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`;
|
const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`;
|
||||||
|
|
||||||
|
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
env("NEXT_PUBLIC_BASE_URL") + "/api/upload/image",
|
`${baseUrl}/api/upload/avatar`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": blob.type,
|
||||||
|
"x-original-filename": fileName,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ filename: fileName, contentType: blob.type }),
|
body: blob,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Failed to get pre-signed URL");
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to upload profile image");
|
||||||
|
}
|
||||||
|
|
||||||
const { url } = (await response.json()) as { url: string };
|
// User image is updated in the backend, refresh user data
|
||||||
|
await utils.user.getUser.refetch();
|
||||||
const uploadResponse = await fetch(url, {
|
|
||||||
method: "PUT",
|
showPopup({
|
||||||
body: blob,
|
header: t`Profile image updated`,
|
||||||
|
message: t`Your profile image has been updated.`,
|
||||||
|
icon: "success",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!uploadResponse.ok) throw new Error("Failed to upload profile image");
|
|
||||||
|
|
||||||
updateUser.mutate({ image: fileName });
|
|
||||||
setCropDialogOpen(false);
|
setCropDialogOpen(false);
|
||||||
resetCropState();
|
resetCropState();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -227,7 +208,7 @@ export default function Avatar({
|
|||||||
resetCropState,
|
resetCropState,
|
||||||
selectedFile,
|
selectedFile,
|
||||||
showPopup,
|
showPopup,
|
||||||
updateUser,
|
utils.user.getUser,
|
||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
|
import type { api } from "~/utils/api";
|
||||||
|
|
||||||
|
type ResetMutation = ReturnType<
|
||||||
|
typeof api.permission.resetWorkspaceMemberPermissions.useMutation
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function ClearCustomPermissionsConfirmation({
|
||||||
|
resetAllOverrides,
|
||||||
|
}: {
|
||||||
|
resetAllOverrides: ResetMutation;
|
||||||
|
}) {
|
||||||
|
const { closeModal } = useModal();
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!workspace.publicId || resetAllOverrides.isPending) return;
|
||||||
|
resetAllOverrides.mutate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex w-full flex-col justify-between pb-4">
|
||||||
|
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||||
|
{t`Clear all custom permissions?`}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
|
||||||
|
{t`This will remove all custom member permissions in this workspace. Members will inherit permissions only from their roles.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => closeModal()}>
|
||||||
|
{t`Cancel`}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
isLoading={resetAllOverrides.isPending}
|
||||||
|
>
|
||||||
|
{t`Clear custom permissions`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
196
apps/web/src/views/settings/components/RolePermissions.tsx
Normal file
196
apps/web/src/views/settings/components/RolePermissions.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
|
import { permissionCategories, roles } from "@kan/shared";
|
||||||
|
import type { Permission, Role } from "@kan/shared";
|
||||||
|
|
||||||
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
function formatRoleLabel(role: Role) {
|
||||||
|
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionLabels: Record<Permission, string> = {
|
||||||
|
"workspace:view": t`Can view workspace`,
|
||||||
|
"workspace:edit": t`Can edit workspace`,
|
||||||
|
"workspace:delete": t`Can delete workspace`,
|
||||||
|
"workspace:manage": t`Can manage workspace settings`,
|
||||||
|
|
||||||
|
"board:view": t`Can view boards`,
|
||||||
|
"board:create": t`Can create boards`,
|
||||||
|
"board:edit": t`Can edit boards`,
|
||||||
|
"board:delete": t`Can delete boards`,
|
||||||
|
|
||||||
|
"list:view": t`Can view lists`,
|
||||||
|
"list:create": t`Can create lists`,
|
||||||
|
"list:edit": t`Can edit lists`,
|
||||||
|
"list:delete": t`Can delete lists`,
|
||||||
|
|
||||||
|
"card:view": t`Can view cards`,
|
||||||
|
"card:create": t`Can create cards`,
|
||||||
|
"card:edit": t`Can edit cards`,
|
||||||
|
"card:delete": t`Can delete cards`,
|
||||||
|
|
||||||
|
"comment:view": t`Can view comments`,
|
||||||
|
"comment:create": t`Can add comments`,
|
||||||
|
"comment:edit": t`Can edit comments`,
|
||||||
|
"comment:delete": t`Can delete comments`,
|
||||||
|
|
||||||
|
"member:view": t`Can view members`,
|
||||||
|
"member:invite": t`Can invite members`,
|
||||||
|
"member:edit": t`Can edit member roles and permissions`,
|
||||||
|
"member:remove": t`Can remove members`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RolePermissions() {
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { data, isLoading } =
|
||||||
|
api.permission.getWorkspaceRolePermissions.useQuery(
|
||||||
|
{ workspacePublicId: workspace.publicId },
|
||||||
|
{ enabled: !!workspace.publicId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const systemRoles = (data?.roles ?? []).filter((role) =>
|
||||||
|
(roles).includes(role.name as Role),
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderedRoleNames: Role[] = ["admin", "member", "guest"].filter(
|
||||||
|
(role) => systemRoles.some((r) => r.name === role),
|
||||||
|
) as Role[];
|
||||||
|
|
||||||
|
const grantMutation = api.permission.grantRolePermission.useMutation({
|
||||||
|
onSettled: async () => {
|
||||||
|
if (!workspace.publicId) return;
|
||||||
|
await utils.permission.getWorkspaceRolePermissions.invalidate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const revokeMutation = api.permission.revokeRolePermission.useMutation({
|
||||||
|
onSettled: async () => {
|
||||||
|
if (!workspace.publicId) return;
|
||||||
|
await utils.permission.getWorkspaceRolePermissions.invalidate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBusy = grantMutation.isPending || revokeMutation.isPending;
|
||||||
|
|
||||||
|
const handleToggle = (
|
||||||
|
rolePublicId: string,
|
||||||
|
permission: Permission,
|
||||||
|
checked: boolean,
|
||||||
|
) => {
|
||||||
|
if (!workspace.publicId || !rolePublicId) return;
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
grantMutation.mutate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
rolePublicId,
|
||||||
|
permission,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
revokeMutation.mutate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
rolePublicId,
|
||||||
|
permission,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
{orderedRoleNames.length === 0 && !isLoading ? (
|
||||||
|
<p className="mb-4 text-sm text-neutral-500 dark:text-dark-800">
|
||||||
|
{t`No roles found for this workspace yet.`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-md border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-100">
|
||||||
|
<table className="min-w-full table-fixed divide-y divide-light-600 overflow-visible text-left text-sm dark:divide-dark-600">
|
||||||
|
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-300">
|
||||||
|
<tr>
|
||||||
|
<th className="w-1/2 rounded-tl-lg px-4 py-3 text-left text-xs font-semibold tracking-wide text-light-900 dark:text-dark-900">
|
||||||
|
{t`Permission`}
|
||||||
|
</th>
|
||||||
|
{orderedRoleNames.map((role) => (
|
||||||
|
<th
|
||||||
|
key={role}
|
||||||
|
className="w-1/6 px-4 py-3 text-center text-xs font-semibold tracking-wide text-light-900 dark:text-dark-900"
|
||||||
|
>
|
||||||
|
{formatRoleLabel(role)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{Object.values(permissionCategories).map((category) => (
|
||||||
|
<tbody
|
||||||
|
key={category.label}
|
||||||
|
className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100"
|
||||||
|
>
|
||||||
|
<tr className="bg-light-100 dark:bg-dark-200">
|
||||||
|
<td
|
||||||
|
colSpan={1 + orderedRoleNames.length}
|
||||||
|
className="px-4 py-2 text-xs font-semibold tracking-wide text-light-900 dark:text-dark-900"
|
||||||
|
>
|
||||||
|
{category.label}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{category.permissions.map((permission) => (
|
||||||
|
<tr key={permission}>
|
||||||
|
<td className="w-1/2 px-4 py-2 text-sm text-light-900 dark:text-dark-900">
|
||||||
|
{permissionLabels[permission] ?? permission}
|
||||||
|
</td>
|
||||||
|
{orderedRoleNames.map((roleName) => {
|
||||||
|
const role = systemRoles.find((r) => r.name === roleName);
|
||||||
|
const checked = role?.permissions.includes(permission);
|
||||||
|
const isAdminRole = roleName === "admin";
|
||||||
|
const isBillingOrDeletePermission =
|
||||||
|
permission === "workspace:manage" ||
|
||||||
|
permission === "workspace:delete";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={roleName}
|
||||||
|
className="w-1/6 px-4 py-2 text-center align-middle"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500 disabled:opacity-60"
|
||||||
|
disabled={
|
||||||
|
isAdminRole ||
|
||||||
|
isBillingOrDeletePermission ||
|
||||||
|
!role ||
|
||||||
|
isLoading ||
|
||||||
|
isBusy
|
||||||
|
}
|
||||||
|
checked={!!checked}
|
||||||
|
onChange={(e) =>
|
||||||
|
!isAdminRole &&
|
||||||
|
!isBillingOrDeletePermission &&
|
||||||
|
role &&
|
||||||
|
handleToggle(
|
||||||
|
role.publicId,
|
||||||
|
permission,
|
||||||
|
e.target.checked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
))}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,9 +11,11 @@ import { api } from "~/utils/api";
|
|||||||
const UpdateWorkspaceDescriptionForm = ({
|
const UpdateWorkspaceDescriptionForm = ({
|
||||||
workspacePublicId,
|
workspacePublicId,
|
||||||
workspaceDescription,
|
workspaceDescription,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
workspacePublicId: string;
|
workspacePublicId: string;
|
||||||
workspaceDescription: string;
|
workspaceDescription: string;
|
||||||
|
disabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
@@ -78,9 +80,10 @@ const UpdateWorkspaceDescriptionForm = ({
|
|||||||
<Input
|
<Input
|
||||||
{...register("description")}
|
{...register("description")}
|
||||||
errorMessage={errors.description?.message}
|
errorMessage={errors.description?.message}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isDirty && (
|
{isDirty && !disabled && (
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit(onSubmit)}
|
onClick={handleSubmit(onSubmit)}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { api } from "~/utils/api";
|
|||||||
export default function UpdateWorkspaceEmailVisibilityForm({
|
export default function UpdateWorkspaceEmailVisibilityForm({
|
||||||
workspacePublicId,
|
workspacePublicId,
|
||||||
showEmailsToMembers,
|
showEmailsToMembers,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
workspacePublicId: string;
|
workspacePublicId: string;
|
||||||
showEmailsToMembers: boolean;
|
showEmailsToMembers: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [isChecked, setIsChecked] = useState(showEmailsToMembers);
|
const [isChecked, setIsChecked] = useState(showEmailsToMembers);
|
||||||
@@ -20,13 +22,16 @@ export default function UpdateWorkspaceEmailVisibilityForm({
|
|||||||
|
|
||||||
const updateWorkspace = api.workspace.update.useMutation({
|
const updateWorkspace = api.workspace.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void utils.workspace.byId.invalidate({
|
if (workspacePublicId && workspacePublicId.length >= 12) {
|
||||||
workspacePublicId,
|
void utils.workspace.byId.invalidate({
|
||||||
});
|
workspacePublicId,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
|
if (disabled) return;
|
||||||
const newValue = !isChecked;
|
const newValue = !isChecked;
|
||||||
setIsChecked(newValue);
|
setIsChecked(newValue);
|
||||||
updateWorkspace.mutate({
|
updateWorkspace.mutate({
|
||||||
@@ -46,7 +51,7 @@ export default function UpdateWorkspaceEmailVisibilityForm({
|
|||||||
isChecked={isChecked}
|
isChecked={isChecked}
|
||||||
onChange={handleToggle}
|
onChange={handleToggle}
|
||||||
label=""
|
label=""
|
||||||
disabled={updateWorkspace.isPending}
|
disabled={disabled || updateWorkspace.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ type FormValues = z.infer<typeof schema>;
|
|||||||
const UpdateWorkspaceNameForm = ({
|
const UpdateWorkspaceNameForm = ({
|
||||||
workspacePublicId,
|
workspacePublicId,
|
||||||
workspaceName,
|
workspaceName,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
workspacePublicId: string;
|
workspacePublicId: string;
|
||||||
workspaceName: string;
|
workspaceName: string;
|
||||||
|
disabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
@@ -70,9 +72,13 @@ const UpdateWorkspaceNameForm = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
|
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
|
||||||
<Input {...register("name")} errorMessage={errors.name?.message} />
|
<Input
|
||||||
|
{...register("name")}
|
||||||
|
errorMessage={errors.name?.message}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isDirty && (
|
{isDirty && !disabled && (
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit(onSubmit)}
|
onClick={handleSubmit(onSubmit)}
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ const UpdateWorkspaceUrlForm = ({
|
|||||||
workspacePublicId,
|
workspacePublicId,
|
||||||
workspaceUrl,
|
workspaceUrl,
|
||||||
workspacePlan,
|
workspacePlan,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
workspacePublicId: string;
|
workspacePublicId: string;
|
||||||
workspaceUrl: string;
|
workspaceUrl: string;
|
||||||
workspacePlan: "free" | "pro" | "enterprise";
|
workspacePlan: "free" | "pro" | "enterprise";
|
||||||
|
disabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
@@ -136,9 +138,10 @@ const UpdateWorkspaceUrlForm = ({
|
|||||||
<HiCheck className="h-4 w-4 dark:text-dark-1000" />
|
<HiCheck className="h-4 w-4 dark:text-dark-1000" />
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isDirty && (
|
{isDirty && !disabled && (
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit(onSubmit)}
|
onClick={handleSubmit(onSubmit)}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
"./utils/rateLimit": {
|
"./utils/rateLimit": {
|
||||||
"types": "./dist/utils/rateLimit.d.ts",
|
"types": "./dist/utils/rateLimit.d.ts",
|
||||||
"default": "./src/utils/rateLimit.ts"
|
"default": "./src/utils/rateLimit.ts"
|
||||||
|
},
|
||||||
|
"./utils/permissions": {
|
||||||
|
"types": "./dist/utils/permissions.d.ts",
|
||||||
|
"default": "./src/utils/permissions.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
@@ -39,8 +43,6 @@
|
|||||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.802.0",
|
|
||||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
|
||||||
"@kan/auth": "workspace:*",
|
"@kan/auth": "workspace:*",
|
||||||
"@kan/db": "workspace:*",
|
"@kan/db": "workspace:*",
|
||||||
"@kan/email": "workspace:^",
|
"@kan/email": "workspace:^",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { integrationRouter } from "./routers/integration";
|
|||||||
import { labelRouter } from "./routers/label";
|
import { labelRouter } from "./routers/label";
|
||||||
import { listRouter } from "./routers/list";
|
import { listRouter } from "./routers/list";
|
||||||
import { memberRouter } from "./routers/member";
|
import { memberRouter } from "./routers/member";
|
||||||
|
import { permissionRouter } from "./routers/permission";
|
||||||
import { userRouter } from "./routers/user";
|
import { userRouter } from "./routers/user";
|
||||||
import { workspaceRouter } from "./routers/workspace";
|
import { workspaceRouter } from "./routers/workspace";
|
||||||
import { createTRPCRouter } from "./trpc";
|
import { createTRPCRouter } from "./trpc";
|
||||||
@@ -24,6 +25,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
list: listRouter,
|
list: listRouter,
|
||||||
member: memberRouter,
|
member: memberRouter,
|
||||||
import: importRouter,
|
import: importRouter,
|
||||||
|
permission: permissionRouter,
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
workspace: workspaceRouter,
|
workspace: workspaceRouter,
|
||||||
integration: integrationRouter,
|
integration: integrationRouter,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertPermission } from "../utils/permissions";
|
||||||
import { deleteObject, generateUploadUrl } from "../utils/s3";
|
import { deleteObject, generateUploadUrl } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const attachmentRouter = createTRPCRouter({
|
export const attachmentRouter = createTRPCRouter({
|
||||||
generateUploadUrl: protectedProcedure
|
generateUploadUrl: protectedProcedure
|
||||||
@@ -55,8 +55,7 @@ export const attachmentRouter = createTRPCRouter({
|
|||||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
|
||||||
|
|
||||||
// Get workspace publicId
|
// Get workspace publicId
|
||||||
const workspace = await workspaceRepo.getById(ctx.db, card.workspaceId);
|
const workspace = await workspaceRepo.getById(ctx.db, card.workspaceId);
|
||||||
@@ -131,8 +130,7 @@ export const attachmentRouter = createTRPCRouter({
|
|||||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
|
||||||
|
|
||||||
const attachment = await cardAttachmentRepo.create(ctx.db, {
|
const attachment = await cardAttachmentRepo.create(ctx.db, {
|
||||||
cardId: card.id,
|
cardId: card.id,
|
||||||
@@ -186,8 +184,7 @@ export const attachmentRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const workspaceId = attachment.card.list.board.workspaceId;
|
const workspaceId = attachment.card.list.board.workspaceId;
|
||||||
|
await assertPermission(ctx.db, userId, workspaceId, "card:edit");
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspaceId);
|
|
||||||
|
|
||||||
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||||
if (bucket) {
|
if (bucket) {
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|||||||
import { colours } from "@kan/shared/constants";
|
import { colours } from "@kan/shared/constants";
|
||||||
import {
|
import {
|
||||||
convertDueDateFiltersToRanges,
|
convertDueDateFiltersToRanges,
|
||||||
|
generateAvatarUrl,
|
||||||
generateSlug,
|
generateSlug,
|
||||||
generateUID,
|
generateUID,
|
||||||
} from "@kan/shared/utils";
|
} from "@kan/shared/utils";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||||
|
|
||||||
export const boardRouter = createTRPCRouter({
|
export const boardRouter = createTRPCRouter({
|
||||||
all: protectedProcedure
|
all: protectedProcedure
|
||||||
@@ -58,11 +59,14 @@ export const boardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
await assertPermission(ctx.db, userId, workspace.id, "board:view");
|
||||||
|
|
||||||
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id, {
|
const result = boardRepo.getAllByWorkspaceId(
|
||||||
type: input.type,
|
ctx.db,
|
||||||
});
|
workspace.id,
|
||||||
|
userId,
|
||||||
|
{ type: input.type }
|
||||||
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
@@ -119,7 +123,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
await assertPermission(ctx.db, userId, board.workspaceId, "board:view");
|
||||||
|
|
||||||
// Convert semantic string filters to date ranges expected by the repo
|
// Convert semantic string filters to date ranges expected by the repo
|
||||||
const dueDateFilters = input.dueDateFilters
|
const dueDateFilters = input.dueDateFilters
|
||||||
@@ -129,6 +133,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
const result = await boardRepo.getByPublicId(
|
const result = await boardRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
input.boardPublicId,
|
input.boardPublicId,
|
||||||
|
userId,
|
||||||
{
|
{
|
||||||
members: input.members ?? [],
|
members: input.members ?? [],
|
||||||
labels: input.labels ?? [],
|
labels: input.labels ?? [],
|
||||||
@@ -138,7 +143,33 @@ export const boardRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
// Generate presigned URLs for workspace member avatars
|
||||||
|
const workspaceWithAvatarUrls = result.workspace
|
||||||
|
? {
|
||||||
|
...result.workspace,
|
||||||
|
members: await Promise.all(
|
||||||
|
result.workspace.members.map(async (member) => {
|
||||||
|
if (!member.user?.image) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
user: {
|
||||||
|
...member.user,
|
||||||
|
image: avatarUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: result.workspace;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
workspace: workspaceWithAvatarUrls,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
bySlug: publicProcedure
|
bySlug: publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
@@ -255,7 +286,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
await assertPermission(ctx.db, userId, workspace.id, "board:create");
|
||||||
|
|
||||||
// If sourceBoardPublicId is provided, clone the source board
|
// If sourceBoardPublicId is provided, clone the source board
|
||||||
if (input.sourceBoardPublicId) {
|
if (input.sourceBoardPublicId) {
|
||||||
@@ -275,6 +306,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
const sourceBoard = await boardRepo.getByPublicId(
|
const sourceBoard = await boardRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
input.sourceBoardPublicId,
|
input.sourceBoardPublicId,
|
||||||
|
userId,
|
||||||
{
|
{
|
||||||
members: [],
|
members: [],
|
||||||
labels: [],
|
labels: [],
|
||||||
@@ -399,9 +431,10 @@ export const boardRouter = createTRPCRouter({
|
|||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||||
.optional(),
|
.optional(),
|
||||||
visibility: z.enum(["public", "private"]).optional(),
|
visibility: z.enum(["public", "private"]).optional(),
|
||||||
|
favorite: z.boolean().optional()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>())
|
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.user?.id;
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
@@ -422,7 +455,30 @@ export const boardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
await assertCanEdit(
|
||||||
|
ctx.db,
|
||||||
|
userId,
|
||||||
|
board.workspaceId,
|
||||||
|
"board:edit",
|
||||||
|
board.createdBy ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle favorite toggle separately
|
||||||
|
if (input.favorite !== undefined) {
|
||||||
|
if (input.favorite) {
|
||||||
|
await boardRepo.addUserFavorite(ctx.db, userId, board.id);
|
||||||
|
} else {
|
||||||
|
await boardRepo.removeUserFavorite(ctx.db, userId, board.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other updates (name, slug, visibility)
|
||||||
|
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined;
|
||||||
|
|
||||||
|
if (!hasOtherUpdates) {
|
||||||
|
// Only favorite was updated, return success
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
if (input.slug) {
|
if (input.slug) {
|
||||||
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
||||||
@@ -491,7 +547,13 @@ export const boardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
await assertCanDelete(
|
||||||
|
ctx.db,
|
||||||
|
userId,
|
||||||
|
board.workspaceId,
|
||||||
|
"board:delete",
|
||||||
|
board.createdBy ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
const listIds = board.lists.map((list) => list.id);
|
const listIds = board.lists.map((list) => list.id);
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { mergeActivities } from "../utils/activities";
|
import { mergeActivities } from "../utils/activities";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||||
import { generateDownloadUrl } from "../utils/s3";
|
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const cardRouter = createTRPCRouter({
|
export const cardRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
@@ -57,13 +57,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, list.workspaceId);
|
await assertPermission(ctx.db, userId, list.workspaceId, "card:create");
|
||||||
|
|
||||||
if (!userId)
|
|
||||||
throw new TRPCError({
|
|
||||||
message: `User not authenticated`,
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
});
|
|
||||||
|
|
||||||
const newCard = await cardRepo.create(ctx.db, {
|
const newCard = await cardRepo.create(ctx.db, {
|
||||||
title: input.title,
|
title: input.title,
|
||||||
@@ -199,7 +193,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "comment:create");
|
||||||
|
|
||||||
const newComment = await cardCommentRepo.create(ctx.db, {
|
const newComment = await cardCommentRepo.create(ctx.db, {
|
||||||
comment: input.comment,
|
comment: input.comment,
|
||||||
@@ -262,8 +256,6 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
|
||||||
|
|
||||||
const existingComment = await cardCommentRepo.getByPublicId(
|
const existingComment = await cardCommentRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
input.commentPublicId,
|
input.commentPublicId,
|
||||||
@@ -275,11 +267,13 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingComment.createdBy !== userId)
|
await assertCanEdit(
|
||||||
throw new TRPCError({
|
ctx.db,
|
||||||
message: `You do not have permission to update this comment`,
|
userId,
|
||||||
code: "FORBIDDEN",
|
card.workspaceId,
|
||||||
});
|
"comment:edit",
|
||||||
|
existingComment.createdBy,
|
||||||
|
);
|
||||||
|
|
||||||
const updatedComment = await cardCommentRepo.update(ctx.db, {
|
const updatedComment = await cardCommentRepo.update(ctx.db, {
|
||||||
id: existingComment.id,
|
id: existingComment.id,
|
||||||
@@ -340,8 +334,6 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
|
||||||
|
|
||||||
const existingComment = await cardCommentRepo.getByPublicId(
|
const existingComment = await cardCommentRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
input.commentPublicId,
|
input.commentPublicId,
|
||||||
@@ -353,6 +345,14 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await assertCanDelete(
|
||||||
|
ctx.db,
|
||||||
|
userId,
|
||||||
|
card.workspaceId,
|
||||||
|
"comment:delete",
|
||||||
|
existingComment.createdBy,
|
||||||
|
);
|
||||||
|
|
||||||
const deletedComment = await cardCommentRepo.softDelete(ctx.db, {
|
const deletedComment = await cardCommentRepo.softDelete(ctx.db, {
|
||||||
commentId: existingComment.id,
|
commentId: existingComment.id,
|
||||||
deletedAt: new Date(),
|
deletedAt: new Date(),
|
||||||
@@ -412,7 +412,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||||
|
|
||||||
const label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
|
const label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
|
||||||
|
|
||||||
@@ -504,7 +504,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||||
|
|
||||||
const member = await workspaceRepo.getMemberByPublicId(
|
const member = await workspaceRepo.getMemberByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
@@ -616,7 +616,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "card:view");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await cardRepo.getWithListAndMembersByPublicId(
|
const result = await cardRepo.getWithListAndMembersByPublicId(
|
||||||
@@ -631,45 +631,54 @@ export const cardRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Generate URLs for all attachments
|
// Generate URLs for all attachments
|
||||||
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
const attachmentsWithUrls = await Promise.all(
|
||||||
if (result.attachments && Array.isArray(result.attachments)) {
|
result.attachments.map(async (attachment) => {
|
||||||
const attachments = result.attachments as {
|
const url = await generateAttachmentUrl(attachment.s3Key);
|
||||||
publicId: string;
|
return {
|
||||||
contentType: string;
|
publicId: attachment.publicId,
|
||||||
s3Key: string;
|
contentType: attachment.contentType,
|
||||||
originalFilename: string | null;
|
s3Key: attachment.s3Key,
|
||||||
size?: number | null;
|
originalFilename: attachment.originalFilename,
|
||||||
}[];
|
size: attachment.size,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const attachmentsWithUrls = await Promise.all(
|
// Generate presigned URLs for workspace member avatars
|
||||||
attachments.map(async (attachment) => {
|
const workspaceWithAvatarUrls = result.list.board.workspace
|
||||||
const base = {
|
? {
|
||||||
publicId: attachment.publicId,
|
...result.list.board.workspace,
|
||||||
contentType: attachment.contentType,
|
members: await Promise.all(
|
||||||
s3Key: attachment.s3Key,
|
result.list.board.workspace.members.map(async (member) => {
|
||||||
originalFilename: attachment.originalFilename,
|
if (!member.user?.image) {
|
||||||
size: attachment.size,
|
return member;
|
||||||
};
|
}
|
||||||
if (!bucket || !attachment.s3Key) {
|
|
||||||
return { ...base, url: null };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const url = await generateDownloadUrl(
|
|
||||||
bucket,
|
|
||||||
attachment.s3Key,
|
|
||||||
86400, // 24 hours expiration
|
|
||||||
);
|
|
||||||
return { ...base, url };
|
|
||||||
} catch {
|
|
||||||
// If URL generation fails, return attachment with url: null
|
|
||||||
return { ...base, url: null };
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return { ...result, attachments: attachmentsWithUrls };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...result, attachments: [] };
|
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
user: {
|
||||||
|
...member.user,
|
||||||
|
image: avatarUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: result.list.board.workspace;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
attachments: attachmentsWithUrls,
|
||||||
|
list: {
|
||||||
|
...result.list,
|
||||||
|
board: {
|
||||||
|
...result.list.board,
|
||||||
|
workspace: workspaceWithAvatarUrls,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
getActivities: publicProcedure
|
getActivities: publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
@@ -725,7 +734,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertPermission(ctx.db, userId, card.workspaceId, "card:view");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cursor = input.cursor ? new Date(input.cursor) : undefined;
|
const cursor = input.cursor ? new Date(input.cursor) : undefined;
|
||||||
@@ -738,7 +747,39 @@ export const cardRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const mergedActivities = mergeActivities(result.activities);
|
// Generate presigned URLs for user avatars in activities
|
||||||
|
const activitiesWithAvatarUrls = await Promise.all(
|
||||||
|
result.activities.map(async (activity) => {
|
||||||
|
const updatedActivity = { ...activity };
|
||||||
|
|
||||||
|
// Generate presigned URL for activity user avatar
|
||||||
|
if (activity.user?.image) {
|
||||||
|
const userAvatarUrl = await generateAvatarUrl(activity.user.image);
|
||||||
|
updatedActivity.user = {
|
||||||
|
...activity.user,
|
||||||
|
image: userAvatarUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate presigned URL for member user avatar (if exists)
|
||||||
|
if (activity.member?.user?.image) {
|
||||||
|
const memberAvatarUrl = await generateAvatarUrl(
|
||||||
|
activity.member.user.image,
|
||||||
|
);
|
||||||
|
updatedActivity.member = {
|
||||||
|
...activity.member,
|
||||||
|
user: {
|
||||||
|
...activity.member.user,
|
||||||
|
image: memberAvatarUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedActivity;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedActivities = mergeActivities(activitiesWithAvatarUrls);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activities: mergedActivities,
|
activities: mergedActivities,
|
||||||
@@ -788,7 +829,13 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertCanEdit(
|
||||||
|
ctx.db,
|
||||||
|
userId,
|
||||||
|
card.workspaceId,
|
||||||
|
"card:edit",
|
||||||
|
card.createdBy,
|
||||||
|
);
|
||||||
|
|
||||||
const existingCard = await cardRepo.getByPublicId(
|
const existingCard = await cardRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
@@ -958,7 +1005,13 @@ export const cardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertCanDelete(
|
||||||
|
ctx.db,
|
||||||
|
userId,
|
||||||
|
card.workspaceId,
|
||||||
|
"card:delete",
|
||||||
|
card.createdBy,
|
||||||
|
);
|
||||||
|
|
||||||
const deletedAt = new Date();
|
const deletedAt = new Date();
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
|||||||
import * as checklistRepo from "@kan/db/repository/checklist.repo";
|
import * as checklistRepo from "@kan/db/repository/checklist.repo";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertPermission } from "../utils/permissions";
|
||||||
|
|
||||||
const checklistSchema = z.object({
|
const checklistSchema = z.object({
|
||||||
publicId: z.string().length(12),
|
publicId: z.string().length(12),
|
||||||
@@ -57,8 +57,7 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
|
||||||
|
|
||||||
const newChecklist = await checklistRepo.create(ctx.db, {
|
const newChecklist = await checklistRepo.create(ctx.db, {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@@ -106,11 +105,11 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(
|
||||||
await assertUserInWorkspace(
|
|
||||||
ctx.db,
|
ctx.db,
|
||||||
userId,
|
userId,
|
||||||
checklist.card.list.board.workspace.id,
|
checklist.card.list.board.workspace.id,
|
||||||
|
"card:edit",
|
||||||
);
|
);
|
||||||
|
|
||||||
const previousName = checklist.name;
|
const previousName = checklist.name;
|
||||||
@@ -166,11 +165,11 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(
|
||||||
await assertUserInWorkspace(
|
|
||||||
ctx.db,
|
ctx.db,
|
||||||
userId,
|
userId,
|
||||||
checklist.card.list.board.workspace.id,
|
checklist.card.list.board.workspace.id,
|
||||||
|
"card:edit",
|
||||||
);
|
);
|
||||||
|
|
||||||
await checklistRepo.softDeleteAllItemsByChecklistId(ctx.db, {
|
await checklistRepo.softDeleteAllItemsByChecklistId(ctx.db, {
|
||||||
@@ -237,11 +236,11 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(
|
||||||
await assertUserInWorkspace(
|
|
||||||
ctx.db,
|
ctx.db,
|
||||||
userId,
|
userId,
|
||||||
checklist.card.list.board.workspace.id,
|
checklist.card.list.board.workspace.id,
|
||||||
|
"card:edit",
|
||||||
);
|
);
|
||||||
|
|
||||||
const newChecklistItem = await checklistRepo.createItem(ctx.db, {
|
const newChecklistItem = await checklistRepo.createItem(ctx.db, {
|
||||||
@@ -304,11 +303,11 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
|
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(
|
||||||
await assertUserInWorkspace(
|
|
||||||
ctx.db,
|
ctx.db,
|
||||||
userId,
|
userId,
|
||||||
item.checklist.card.list.board.workspace.id,
|
item.checklist.card.list.board.workspace.id,
|
||||||
|
"card:edit",
|
||||||
);
|
);
|
||||||
|
|
||||||
const previousTitle = item.title;
|
const previousTitle = item.title;
|
||||||
@@ -394,11 +393,11 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
|
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(
|
||||||
await assertUserInWorkspace(
|
|
||||||
ctx.db,
|
ctx.db,
|
||||||
userId,
|
userId,
|
||||||
item.checklist.card.list.board.workspace.id,
|
item.checklist.card.list.board.workspace.id,
|
||||||
|
"card:edit",
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleted = await checklistRepo.softDeleteItemById(ctx.db, {
|
const deleted = await checklistRepo.softDeleteItemById(ctx.db, {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "../trpc";
|
} from "../trpc";
|
||||||
import { createS3Client } from "../utils/s3";
|
import { createS3Client } from "@kan/shared/utils";
|
||||||
|
|
||||||
const checkDatabaseConnection = async (db: dbClient) => {
|
const checkDatabaseConnection = async (db: dbClient) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { colours } from "@kan/shared/constants";
|
|||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertPermission } from "../utils/permissions";
|
||||||
import { apiKeys, urls } from "./integration";
|
import { apiKeys, urls } from "./integration";
|
||||||
|
|
||||||
export interface TrelloBoard {
|
export interface TrelloBoard {
|
||||||
@@ -180,8 +180,7 @@ export const importRouter = createTRPCRouter({
|
|||||||
message: `Workspace with public ID ${input.workspacePublicId} not found`,
|
message: `Workspace with public ID ${input.workspacePublicId} not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "board:create");
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
|
||||||
|
|
||||||
const newImport = await importRepo.create(ctx.db, {
|
const newImport = await importRepo.create(ctx.db, {
|
||||||
source: "trello",
|
source: "trello",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import * as cardRepo from "@kan/db/repository/card.repo";
|
|||||||
import * as labelRepo from "@kan/db/repository/label.repo";
|
import * as labelRepo from "@kan/db/repository/label.repo";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertPermission } from "../utils/permissions";
|
||||||
|
|
||||||
const labelSchema = z.object({
|
const labelSchema = z.object({
|
||||||
publicId: z.string(),
|
publicId: z.string(),
|
||||||
@@ -47,8 +47,7 @@ export const labelRouter = createTRPCRouter({
|
|||||||
message: `Label with public ID ${input.labelPublicId} not found`,
|
message: `Label with public ID ${input.labelPublicId} not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(ctx.db, userId, label.workspaceId, "board:view");
|
||||||
await assertUserInWorkspace(ctx.db, userId, label.workspaceId);
|
|
||||||
|
|
||||||
const result = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
|
const result = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
|
||||||
|
|
||||||
@@ -102,8 +101,7 @@ export const labelRouter = createTRPCRouter({
|
|||||||
message: `Board with public ID ${input.boardPublicId} not found`,
|
message: `Board with public ID ${input.boardPublicId} not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(ctx.db, userId, board.workspaceId, "board:edit");
|
||||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
|
||||||
|
|
||||||
const result = await labelRepo.create(ctx.db, {
|
const result = await labelRepo.create(ctx.db, {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@@ -162,8 +160,7 @@ export const labelRouter = createTRPCRouter({
|
|||||||
message: `Label with public ID ${input.labelPublicId} not found`,
|
message: `Label with public ID ${input.labelPublicId} not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(ctx.db, userId, label.workspaceId, "board:edit");
|
||||||
await assertUserInWorkspace(ctx.db, userId, label.workspaceId);
|
|
||||||
|
|
||||||
const result = await labelRepo.update(ctx.db, input);
|
const result = await labelRepo.update(ctx.db, input);
|
||||||
|
|
||||||
@@ -211,8 +208,7 @@ export const labelRouter = createTRPCRouter({
|
|||||||
message: `Label with public ID ${input.labelPublicId} not found`,
|
message: `Label with public ID ${input.labelPublicId} not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(ctx.db, userId, label.workspaceId, "board:edit");
|
||||||
await assertUserInWorkspace(ctx.db, userId, label.workspaceId);
|
|
||||||
|
|
||||||
await cardRepo.hardDeleteAllCardLabelRelationships(ctx.db, label.id);
|
await cardRepo.hardDeleteAllCardLabelRelationships(ctx.db, label.id);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import * as activityRepo from "@kan/db/repository/cardActivity.repo";
|
|||||||
import * as listRepo from "@kan/db/repository/list.repo";
|
import * as listRepo from "@kan/db/repository/list.repo";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||||
|
|
||||||
export const listRouter = createTRPCRouter({
|
export const listRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
@@ -48,7 +48,7 @@ export const listRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
await assertPermission(ctx.db, userId, board.workspaceId, "list:create");
|
||||||
|
|
||||||
const result = await listRepo.create(ctx.db, {
|
const result = await listRepo.create(ctx.db, {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@@ -101,7 +101,13 @@ export const listRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, list.workspaceId);
|
await assertCanDelete(
|
||||||
|
ctx.db,
|
||||||
|
userId,
|
||||||
|
list.workspaceId,
|
||||||
|
"list:delete",
|
||||||
|
list.createdBy,
|
||||||
|
);
|
||||||
|
|
||||||
const deletedAt = new Date();
|
const deletedAt = new Date();
|
||||||
|
|
||||||
@@ -183,7 +189,13 @@ export const listRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, list.workspaceId);
|
await assertCanEdit(
|
||||||
|
ctx.db,
|
||||||
|
userId,
|
||||||
|
list.workspaceId,
|
||||||
|
"list:edit",
|
||||||
|
list.createdBy,
|
||||||
|
);
|
||||||
|
|
||||||
let result: { name: string; publicId: string } | undefined;
|
let result: { name: string; publicId: string } | undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
|
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
|
||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
|
import * as permissionRepo from "@kan/db/repository/permission.repo";
|
||||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
@@ -11,11 +12,15 @@ import {
|
|||||||
generateUID,
|
generateUID,
|
||||||
getSubscriptionByPlan,
|
getSubscriptionByPlan,
|
||||||
hasUnlimitedSeats,
|
hasUnlimitedSeats,
|
||||||
} from "@kan/shared/utils";
|
} from "@kan/shared";
|
||||||
import { updateSubscriptionSeats } from "@kan/stripe";
|
import { updateSubscriptionSeats } from "@kan/stripe";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import {
|
||||||
|
assertCanManageMember,
|
||||||
|
assertCanManageRole,
|
||||||
|
assertPermission,
|
||||||
|
} from "../utils/permissions";
|
||||||
|
|
||||||
export const memberRouter = createTRPCRouter({
|
export const memberRouter = createTRPCRouter({
|
||||||
invite: protectedProcedure
|
invite: protectedProcedure
|
||||||
@@ -56,7 +61,7 @@ export const memberRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
await assertPermission(ctx.db, userId, workspace.id, "member:invite");
|
||||||
|
|
||||||
const isInvitedEmailAlreadyMember = workspace.members.some(
|
const isInvitedEmailAlreadyMember = workspace.members.some(
|
||||||
(member) => member.email === input.email,
|
(member) => member.email === input.email,
|
||||||
@@ -112,12 +117,20 @@ export const memberRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const existingUser = await userRepo.getByEmail(ctx.db, input.email);
|
const existingUser = await userRepo.getByEmail(ctx.db, input.email);
|
||||||
|
|
||||||
|
// Get the workspace role to set roleId
|
||||||
|
const memberRole = await permissionRepo.getRoleByWorkspaceIdAndName(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
"member",
|
||||||
|
);
|
||||||
|
|
||||||
const invite = await memberRepo.create(ctx.db, {
|
const invite = await memberRepo.create(ctx.db, {
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
email: input.email,
|
email: input.email,
|
||||||
userId: existingUser?.id ?? null,
|
userId: existingUser?.id ?? null,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
role: "member",
|
role: "member",
|
||||||
|
roleId: memberRole?.id ?? null,
|
||||||
status: "invited",
|
status: "invited",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,7 +203,7 @@ export const memberRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
await assertPermission(ctx.db, userId, workspace.id, "member:remove");
|
||||||
|
|
||||||
const member = await memberRepo.getByPublicId(
|
const member = await memberRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
@@ -292,8 +305,8 @@ export const memberRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if user is in workspace
|
// Check if user can view members
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
await assertPermission(ctx.db, userId, workspace.id, "member:view");
|
||||||
|
|
||||||
// Get active invite link for this workspace
|
// Get active invite link for this workspace
|
||||||
const activeInviteLink = await inviteLinkRepo.getActiveForWorkspace(
|
const activeInviteLink = await inviteLinkRepo.getActiveForWorkspace(
|
||||||
@@ -360,8 +373,8 @@ export const memberRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if user is in workspace
|
// Check if user can edit members (admin-equivalent)
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||||
|
|
||||||
// Check subscription for cloud environment
|
// Check subscription for cloud environment
|
||||||
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
||||||
@@ -461,8 +474,8 @@ export const memberRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if user is in workspace
|
// Check if user can edit members (admin-equivalent)
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||||
|
|
||||||
// Deactivate all active invite links
|
// Deactivate all active invite links
|
||||||
await inviteLinkRepo.deactivateAllActiveForWorkspace(ctx.db, {
|
await inviteLinkRepo.deactivateAllActiveForWorkspace(ctx.db, {
|
||||||
@@ -631,12 +644,20 @@ export const memberRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the workspace role to set roleId
|
||||||
|
const memberRole = await permissionRepo.getRoleByWorkspaceIdAndName(
|
||||||
|
ctx.db,
|
||||||
|
invite.workspaceId,
|
||||||
|
"member",
|
||||||
|
);
|
||||||
|
|
||||||
await memberRepo.create(ctx.db, {
|
await memberRepo.create(ctx.db, {
|
||||||
workspaceId: invite.workspaceId,
|
workspaceId: invite.workspaceId,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
createdBy: user.id,
|
createdBy: user.id,
|
||||||
role: "member",
|
role: "member",
|
||||||
|
roleId: memberRole?.id ?? null,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -646,4 +667,85 @@ export const memberRouter = createTRPCRouter({
|
|||||||
workspaceSlug: workspace.slug,
|
workspaceSlug: workspace.slug,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
updateRole: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Update member role",
|
||||||
|
method: "PUT",
|
||||||
|
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/role",
|
||||||
|
description: "Updates a member's role in a workspace",
|
||||||
|
tags: ["Workspaces"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
memberPublicId: z.string().min(12),
|
||||||
|
role: z.enum(["admin", "member", "guest"]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
role: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||||
|
|
||||||
|
const member = await memberRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.memberPublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Member not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertCanManageMember(ctx.db, userId, workspace.id, member.id);
|
||||||
|
await assertCanManageRole(ctx.db, userId, workspace.id, input.role);
|
||||||
|
|
||||||
|
// Get the workspace role to set roleId
|
||||||
|
const workspaceRole = await permissionRepo.getRoleByWorkspaceIdAndName(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
input.role,
|
||||||
|
);
|
||||||
|
|
||||||
|
await memberRepo.updateRole(ctx.db, {
|
||||||
|
memberId: member.id,
|
||||||
|
role: input.role,
|
||||||
|
roleId: workspaceRole?.id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
role: input.role,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
776
packages/api/src/routers/permission.ts
Normal file
776
packages/api/src/routers/permission.ts
Normal file
@@ -0,0 +1,776 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
|
import * as permissionRepo from "@kan/db/repository/permission.repo";
|
||||||
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
|
import type { Permission } from "@kan/shared";
|
||||||
|
import { allPermissions } from "@kan/shared";
|
||||||
|
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import {
|
||||||
|
assertCanManageMember,
|
||||||
|
assertPermission,
|
||||||
|
getMemberEffectivePermissions,
|
||||||
|
getUserPermissions,
|
||||||
|
} from "../utils/permissions";
|
||||||
|
|
||||||
|
const permissionsList = [...allPermissions] as [string, ...string[]];
|
||||||
|
|
||||||
|
export const permissionRouter = createTRPCRouter({
|
||||||
|
getMyPermissions: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Get my permissions",
|
||||||
|
method: "GET",
|
||||||
|
path: "/workspaces/{workspacePublicId}/permissions/me",
|
||||||
|
description: "Get the current user's permissions in a workspace",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
permissions: z.array(z.string()),
|
||||||
|
role: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getUserPermissions(ctx.db, userId, workspace.id);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "You are not a member of this workspace",
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
getMemberPermissions: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Get member permissions",
|
||||||
|
method: "GET",
|
||||||
|
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/permissions",
|
||||||
|
description: "Get a specific member's permissions in a workspace",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
memberPublicId: z.string().min(12),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
memberPublicId: z.string(),
|
||||||
|
role: z.string(),
|
||||||
|
permissions: z.array(z.string()),
|
||||||
|
overrides: z.array(
|
||||||
|
z.object({
|
||||||
|
permission: z.string(),
|
||||||
|
granted: z.boolean(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user has permission to view member permissions
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:view");
|
||||||
|
|
||||||
|
const member = await memberRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.memberPublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Member not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectivePermissions = await getMemberEffectivePermissions(
|
||||||
|
ctx.db,
|
||||||
|
member.id,
|
||||||
|
member.roleId ?? null,
|
||||||
|
member.role,
|
||||||
|
);
|
||||||
|
const overrides = await permissionRepo.getMemberPermissionOverrides(
|
||||||
|
ctx.db,
|
||||||
|
member.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
memberPublicId: member.publicId,
|
||||||
|
role: member.role,
|
||||||
|
permissions: effectivePermissions,
|
||||||
|
overrides: overrides.map((o) => ({
|
||||||
|
permission: o.permission,
|
||||||
|
granted: o.granted,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
grantPermission: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Grant permission to member",
|
||||||
|
method: "POST",
|
||||||
|
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/permissions/grant",
|
||||||
|
description: "Grant a specific permission to a member",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
memberPublicId: z.string().min(12),
|
||||||
|
permission: z.enum(permissionsList),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(z.object({ success: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||||
|
|
||||||
|
const member = await memberRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.memberPublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Member not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertCanManageMember(ctx.db, userId, workspace.id, member.id);
|
||||||
|
|
||||||
|
await permissionRepo.grantPermission(
|
||||||
|
ctx.db,
|
||||||
|
member.id,
|
||||||
|
input.permission as Permission,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
revokePermission: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Revoke permission from member",
|
||||||
|
method: "POST",
|
||||||
|
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/permissions/revoke",
|
||||||
|
description: "Revoke a specific permission from a member",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
memberPublicId: z.string().min(12),
|
||||||
|
permission: z.enum(permissionsList),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(z.object({ success: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||||
|
|
||||||
|
const member = await memberRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.memberPublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Member not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertCanManageMember(ctx.db, userId, workspace.id, member.id);
|
||||||
|
|
||||||
|
await permissionRepo.revokePermission(
|
||||||
|
ctx.db,
|
||||||
|
member.id,
|
||||||
|
input.permission as Permission,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
resetMemberPermissions: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Reset member permissions to role defaults",
|
||||||
|
method: "POST",
|
||||||
|
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}/permissions/reset",
|
||||||
|
description:
|
||||||
|
"Clears all custom permission overrides for a member so their effective permissions come only from their role",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
memberPublicId: z.string().min(12),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(z.object({ success: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||||
|
|
||||||
|
const member = await memberRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.memberPublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Member not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertCanManageMember(ctx.db, userId, workspace.id, member.id);
|
||||||
|
|
||||||
|
await permissionRepo.clearMemberPermissionOverrides(ctx.db, member.id);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
resetWorkspaceMemberPermissions: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Reset all member permission overrides in a workspace",
|
||||||
|
method: "POST",
|
||||||
|
path: "/workspaces/{workspacePublicId}/members/permissions/reset",
|
||||||
|
description:
|
||||||
|
"Clears all custom permission overrides for all members in a workspace so their effective permissions come only from their roles",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(z.object({ success: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||||
|
|
||||||
|
await permissionRepo.clearAllMemberPermissionOverridesForWorkspace(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
getWorkspaceRoles: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Get workspace roles",
|
||||||
|
method: "GET",
|
||||||
|
path: "/workspaces/{workspacePublicId}/roles",
|
||||||
|
description: "Get all roles for a workspace",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
roles: z.array(
|
||||||
|
z.object({
|
||||||
|
publicId: z.string().min(12),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().nullable(),
|
||||||
|
hierarchyLevel: z.number(),
|
||||||
|
isSystem: z.boolean(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:view");
|
||||||
|
|
||||||
|
const roles = await permissionRepo.getRolesByWorkspaceId(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles: roles.map((role) => ({
|
||||||
|
publicId: role.publicId,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description ?? null,
|
||||||
|
hierarchyLevel: role.hierarchyLevel,
|
||||||
|
isSystem: role.isSystem,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
getWorkspaceRolePermissions: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Get workspace role permissions",
|
||||||
|
method: "GET",
|
||||||
|
path: "/workspaces/{workspacePublicId}/roles/permissions",
|
||||||
|
description:
|
||||||
|
"Get all roles for a workspace with their granted permissions",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
roles: z.array(
|
||||||
|
z.object({
|
||||||
|
publicId: z.string().min(12),
|
||||||
|
name: z.string(),
|
||||||
|
permissions: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:view");
|
||||||
|
|
||||||
|
const roles = await permissionRepo.getRolesByWorkspaceId(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rolesWithPermissions = await Promise.all(
|
||||||
|
roles.map(async (role) => {
|
||||||
|
const permissionsForRole = await permissionRepo.getPermissionsByRoleId(
|
||||||
|
ctx.db,
|
||||||
|
role.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicId: role.publicId,
|
||||||
|
name: role.name,
|
||||||
|
permissions: permissionsForRole,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles: rolesWithPermissions,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
getRolePermissions: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Get role permissions",
|
||||||
|
method: "GET",
|
||||||
|
path: "/workspaces/{workspacePublicId}/roles/{rolePublicId}/permissions",
|
||||||
|
description: "Get permissions granted to a specific role in a workspace",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
rolePublicId: z.string().min(12),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
rolePublicId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
permissions: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:view");
|
||||||
|
|
||||||
|
const role = await permissionRepo.getRoleByWorkspaceIdAndPublicId(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
input.rolePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Role not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionsForRole = await permissionRepo.getPermissionsByRoleId(
|
||||||
|
ctx.db,
|
||||||
|
role.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rolePublicId: role.publicId,
|
||||||
|
name: role.name,
|
||||||
|
permissions: permissionsForRole,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
grantRolePermission: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Grant permission to role",
|
||||||
|
method: "POST",
|
||||||
|
path: "/workspaces/{workspacePublicId}/roles/{rolePublicId}/permissions/grant",
|
||||||
|
description: "Grant a specific permission to a role",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
rolePublicId: z.string().min(12),
|
||||||
|
permission: z.enum(permissionsList),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(z.object({ success: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require ability to edit members/roles
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||||
|
|
||||||
|
const role = await permissionRepo.getRoleByWorkspaceIdAndPublicId(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
input.rolePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Role not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.name === "admin" && role.isSystem) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Admin role permissions cannot be modified",
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never allow non-admin roles to manage billing or delete workspace
|
||||||
|
if (
|
||||||
|
(input.permission === "workspace:manage" ||
|
||||||
|
input.permission === "workspace:delete") &&
|
||||||
|
role.name !== "admin"
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message:
|
||||||
|
"Only the admin role can manage billing or delete the workspace",
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await permissionRepo.grantRolePermission(
|
||||||
|
ctx.db,
|
||||||
|
role.id,
|
||||||
|
input.permission as Permission,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
revokeRolePermission: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Revoke permission from role",
|
||||||
|
method: "POST",
|
||||||
|
path: "/workspaces/{workspacePublicId}/roles/{rolePublicId}/permissions/revoke",
|
||||||
|
description: "Revoke a specific permission from a role",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
rolePublicId: z.string().min(12),
|
||||||
|
permission: z.enum(permissionsList),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(z.object({ success: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||||
|
|
||||||
|
const role = await permissionRepo.getRoleByWorkspaceIdAndPublicId(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
input.rolePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Role not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.name === "admin" && role.isSystem) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Admin role permissions cannot be modified",
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await permissionRepo.revokeRolePermission(
|
||||||
|
ctx.db,
|
||||||
|
role.id,
|
||||||
|
input.permission as Permission,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { generateAvatarUrl } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const userRouter = createTRPCRouter({
|
export const userRouter = createTRPCRouter({
|
||||||
getUser: protectedProcedure
|
getUser: protectedProcedure
|
||||||
@@ -55,8 +56,12 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const apiKey = result.apiKeys[0];
|
const apiKey = result.apiKeys[0];
|
||||||
|
|
||||||
|
// Generate presigned URL for avatar
|
||||||
|
const imageUrl = await generateAvatarUrl(result.image);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
image: imageUrl,
|
||||||
apiKey: apiKey ?? null,
|
apiKey: apiKey ?? null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -102,6 +107,12 @@ export const userRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
// Generate presigned URL for avatar
|
||||||
|
const imageUrl = await generateAvatarUrl(result.image);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
image: imageUrl,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
|
|||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertPermission } from "../utils/permissions";
|
||||||
|
import { generateAvatarUrl } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const workspaceRouter = createTRPCRouter({
|
export const workspaceRouter = createTRPCRouter({
|
||||||
all: protectedProcedure
|
all: protectedProcedure
|
||||||
@@ -74,8 +75,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
message: `Workspace not found`,
|
message: `Workspace not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(ctx.db, userId, result.id, "workspace:view");
|
||||||
await assertUserInWorkspace(ctx.db, userId, result.id);
|
|
||||||
|
|
||||||
// Check if user is an admin
|
// Check if user is an admin
|
||||||
const userMember = result.members.find(
|
const userMember = result.members.find(
|
||||||
@@ -84,11 +84,30 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
const isAdmin = userMember?.role === "admin";
|
const isAdmin = userMember?.role === "admin";
|
||||||
|
|
||||||
// Show emails if user is admin OR workspace setting allows it
|
// Show emails if user is admin OR workspace setting allows it
|
||||||
const shouldShowEmails = isAdmin || result.showEmailsToMembers === true;
|
const shouldShowEmails =
|
||||||
|
isAdmin || result.showEmailsToMembers === true;
|
||||||
|
|
||||||
|
// Generate presigned URLs for member avatars
|
||||||
|
const membersWithAvatarUrls = await Promise.all(
|
||||||
|
result.members.map(async (member) => {
|
||||||
|
if (!member.user?.image) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
user: {
|
||||||
|
...member.user,
|
||||||
|
image: avatarUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// If emails should be hidden, filter them out
|
// If emails should be hidden, filter them out
|
||||||
if (!shouldShowEmails) {
|
if (!shouldShowEmails) {
|
||||||
const sanitizedMembers = result.members.map((member) => {
|
const sanitizedMembers = membersWithAvatarUrls.map((member) => {
|
||||||
// If user doesn't have a display name, use anonymous identifier
|
// If user doesn't have a display name, use anonymous identifier
|
||||||
const displayName =
|
const displayName =
|
||||||
member.user?.name?.trim() ?? `anonymous_${member.publicId}`;
|
member.user?.name?.trim() ?? `anonymous_${member.publicId}`;
|
||||||
@@ -120,7 +139,10 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
|
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return {
|
||||||
|
...result,
|
||||||
|
members: membersWithAvatarUrls,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
bySlug: publicProcedure
|
bySlug: publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
@@ -164,8 +186,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
message: `Workspace not found`,
|
message: `Workspace not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(ctx.db, userId, result.id, "workspace:view");
|
||||||
await assertUserInWorkspace(ctx.db, userId, result.id);
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
@@ -296,8 +317,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
message: `Workspace not found`,
|
message: `Workspace not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "workspace:edit");
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
|
||||||
|
|
||||||
if (input.slug) {
|
if (input.slug) {
|
||||||
const reservedOrPremiumWorkspaceSlug =
|
const reservedOrPremiumWorkspaceSlug =
|
||||||
@@ -379,8 +399,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
message: `Workspace not found`,
|
message: `Workspace not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
|
||||||
|
|
||||||
const result = await workspaceRepo.hardDelete(
|
const result = await workspaceRepo.hardDelete(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
@@ -518,8 +537,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
message: `Workspace not found`,
|
message: `Workspace not found`,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "workspace:view");
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
|
||||||
|
|
||||||
const result = await workspaceRepo.searchBoardsAndCards(
|
const result = await workspaceRepo.searchBoardsAndCards(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
|
|||||||
295
packages/api/src/utils/permissions.ts
Normal file
295
packages/api/src/utils/permissions.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import type { dbClient } from "@kan/db/client";
|
||||||
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
|
import * as permissionRepo from "@kan/db/repository/permission.repo";
|
||||||
|
import type { Permission, Role } from "@kan/shared";
|
||||||
|
import { canManageRole, getDefaultPermissions } from "@kan/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get effective permissions for a member by combining role permissions with overrides
|
||||||
|
*/
|
||||||
|
export async function getMemberEffectivePermissions(
|
||||||
|
db: dbClient,
|
||||||
|
workspaceMemberId: number,
|
||||||
|
roleId: number | null,
|
||||||
|
roleName: string,
|
||||||
|
): Promise<Permission[]> {
|
||||||
|
let roleDefaults: Set<Permission>;
|
||||||
|
|
||||||
|
// Get role permissions from database or fallback to code defaults
|
||||||
|
if (roleId) {
|
||||||
|
const dbPermissions = await permissionRepo.getPermissionsByRoleId(
|
||||||
|
db,
|
||||||
|
roleId,
|
||||||
|
);
|
||||||
|
roleDefaults = new Set<Permission>(dbPermissions);
|
||||||
|
} else {
|
||||||
|
const codeDefaults = getDefaultPermissions(roleName as Role);
|
||||||
|
roleDefaults = new Set<Permission>([...codeDefaults]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get and apply custom overrides
|
||||||
|
const overrides = await permissionRepo.getMemberPermissionOverrides(
|
||||||
|
db,
|
||||||
|
workspaceMemberId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const override of overrides) {
|
||||||
|
if (override.granted) {
|
||||||
|
roleDefaults.add(override.permission as Permission);
|
||||||
|
} else {
|
||||||
|
roleDefaults.delete(override.permission as Permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(roleDefaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a member has a specific permission
|
||||||
|
*/
|
||||||
|
export async function memberHasPermission(
|
||||||
|
db: dbClient,
|
||||||
|
workspaceMemberId: number,
|
||||||
|
roleId: number | null,
|
||||||
|
roleName: string,
|
||||||
|
permission: Permission,
|
||||||
|
): Promise<boolean> {
|
||||||
|
let hasRoleDefault: boolean;
|
||||||
|
|
||||||
|
// Check role permission from database or fallback to code defaults
|
||||||
|
if (roleId) {
|
||||||
|
const dbPermissions = await permissionRepo.getPermissionsByRoleId(
|
||||||
|
db,
|
||||||
|
roleId,
|
||||||
|
);
|
||||||
|
hasRoleDefault = dbPermissions.includes(permission);
|
||||||
|
} else {
|
||||||
|
const codeDefaults = getDefaultPermissions(roleName as Role);
|
||||||
|
hasRoleDefault = codeDefaults.includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for override
|
||||||
|
const override = await permissionRepo.getMemberPermissionOverride(
|
||||||
|
db,
|
||||||
|
workspaceMemberId,
|
||||||
|
permission,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Override takes precedence
|
||||||
|
if (override) {
|
||||||
|
return override.granted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasRoleDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has a specific permission in a workspace
|
||||||
|
*/
|
||||||
|
export async function hasPermission(
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
workspaceId: number,
|
||||||
|
permission: Permission,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const member = await permissionRepo.getMemberWithRole(db, userId, workspaceId);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberHasPermission(
|
||||||
|
db,
|
||||||
|
member.id,
|
||||||
|
member.roleId,
|
||||||
|
member.role,
|
||||||
|
permission,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all permissions for a user in a workspace
|
||||||
|
*/
|
||||||
|
export async function getUserPermissions(
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
workspaceId: number,
|
||||||
|
): Promise<{
|
||||||
|
permissions: Permission[];
|
||||||
|
role: string;
|
||||||
|
roleId: number | null;
|
||||||
|
} | null> {
|
||||||
|
const member = await permissionRepo.getMemberWithRole(db, userId, workspaceId);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = await getMemberEffectivePermissions(
|
||||||
|
db,
|
||||||
|
member.id,
|
||||||
|
member.roleId,
|
||||||
|
member.role,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissions,
|
||||||
|
role: member.role,
|
||||||
|
roleId: member.roleId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert user has permission - throws FORBIDDEN if not
|
||||||
|
*/
|
||||||
|
export async function assertPermission(
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
workspaceId: number,
|
||||||
|
permission: Permission,
|
||||||
|
): Promise<void> {
|
||||||
|
const hasIt = await hasPermission(db, userId, workspaceId, permission);
|
||||||
|
|
||||||
|
if (!hasIt) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `You do not have permission to perform this action (${permission})`,
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert user can assign a specific role (based on hierarchy)
|
||||||
|
*/
|
||||||
|
export async function assertCanManageRole(
|
||||||
|
db: dbClient,
|
||||||
|
managerUserId: string,
|
||||||
|
workspaceId: number,
|
||||||
|
targetRoleName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const managerMember = await permissionRepo.getMemberWithRole(
|
||||||
|
db,
|
||||||
|
managerUserId,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!managerMember) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "You are not a member of this workspace",
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const managerRole = managerMember.role;
|
||||||
|
|
||||||
|
if (!canManageRole(managerRole, targetRoleName as Role)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `You cannot assign the "${targetRoleName}" role`,
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert user can manage another member based on role hierarchy
|
||||||
|
*/
|
||||||
|
export async function assertCanManageMember(
|
||||||
|
db: dbClient,
|
||||||
|
managerUserId: string,
|
||||||
|
workspaceId: number,
|
||||||
|
targetMemberId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const managerMember = await permissionRepo.getMemberWithRole(
|
||||||
|
db,
|
||||||
|
managerUserId,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!managerMember) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "You are not a member of this workspace",
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMember = await memberRepo.getById(db, targetMemberId);
|
||||||
|
|
||||||
|
if (!targetMember) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Target member not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const managerRole = managerMember.role;
|
||||||
|
const targetRole = targetMember.role;
|
||||||
|
|
||||||
|
if (!canManageRole(managerRole, targetRole)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "You cannot manage this member due to role hierarchy",
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert user can delete an entity - either has the delete permission OR is the creator
|
||||||
|
*/
|
||||||
|
export async function assertCanDelete(
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
workspaceId: number,
|
||||||
|
permission: Permission,
|
||||||
|
createdBy: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
// Check if user has the general delete permission
|
||||||
|
const hasDeletePermission = await hasPermission(db, userId, workspaceId, permission);
|
||||||
|
|
||||||
|
// If user has permission, allow deletion
|
||||||
|
if (hasDeletePermission) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user doesn't have permission, check if they are the creator
|
||||||
|
if (createdBy && createdBy === userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither condition met - deny deletion
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `You do not have permission to delete this entity (${permission})`,
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert user can edit an entity - either has the edit permission OR is the creator
|
||||||
|
*/
|
||||||
|
export async function assertCanEdit(
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
workspaceId: number,
|
||||||
|
permission: Permission,
|
||||||
|
createdBy: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
// Check if user has the general edit permission
|
||||||
|
const hasEditPermission = await hasPermission(db, userId, workspaceId, permission);
|
||||||
|
|
||||||
|
// If user has permission, allow editing
|
||||||
|
if (hasEditPermission) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user doesn't have permission, check if they are the creator
|
||||||
|
if (createdBy && createdBy === userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither condition met - deny editing
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `You do not have permission to edit this entity (${permission})`,
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
|
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
|
||||||
import { createAuthMiddleware } from "better-auth/api";
|
import { createAuthMiddleware } from "better-auth/api";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
@@ -7,7 +7,7 @@ import type { dbClient } from "@kan/db/client";
|
|||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
import { notificationClient } from "@kan/email";
|
import { notificationClient } from "@kan/email";
|
||||||
import { createEmailUnsubscribeLink } from "@kan/shared";
|
import { createEmailUnsubscribeLink, createS3Client } from "@kan/shared";
|
||||||
|
|
||||||
import { downloadImage } from "./utils";
|
import { downloadImage } from "./utils";
|
||||||
|
|
||||||
@@ -61,20 +61,7 @@ export function createDatabaseHooks(db: dbClient) {
|
|||||||
!user.image.includes(storageDomain)
|
!user.image.includes(storageDomain)
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const credentials =
|
const client = createS3Client();
|
||||||
env("S3_ACCESS_KEY_ID") && env("S3_SECRET_ACCESS_KEY")
|
|
||||||
? {
|
|
||||||
accessKeyId: env("S3_ACCESS_KEY_ID")!,
|
|
||||||
secretAccessKey: env("S3_SECRET_ACCESS_KEY")!,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const client = new S3Client({
|
|
||||||
region: env("S3_REGION") ?? "",
|
|
||||||
endpoint: env("S3_ENDPOINT") ?? "",
|
|
||||||
forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true",
|
|
||||||
credentials,
|
|
||||||
});
|
|
||||||
|
|
||||||
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean NOT NULL DEFAULT true;
|
ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "showEmailsToMembers" boolean NOT NULL DEFAULT true;
|
||||||
|
|||||||
171
packages/db/migrations/20260126135909_AddWorkspaceRoles.sql
Normal file
171
packages/db/migrations/20260126135909_AddWorkspaceRoles.sql
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "workspace_member_permissions" (
|
||||||
|
"id" bigserial PRIMARY KEY NOT NULL,
|
||||||
|
"workspaceMemberId" bigint NOT NULL,
|
||||||
|
"permission" varchar(64) NOT NULL,
|
||||||
|
"granted" boolean DEFAULT true NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace_member_permissions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "workspace_role_permissions" (
|
||||||
|
"id" bigserial PRIMARY KEY NOT NULL,
|
||||||
|
"workspaceRoleId" bigint NOT NULL,
|
||||||
|
"permission" varchar(64) NOT NULL,
|
||||||
|
"granted" boolean DEFAULT true NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace_role_permissions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "workspace_roles" (
|
||||||
|
"id" bigserial PRIMARY KEY NOT NULL,
|
||||||
|
"publicId" varchar(12) NOT NULL,
|
||||||
|
"workspaceId" bigint NOT NULL,
|
||||||
|
"name" varchar(64) NOT NULL,
|
||||||
|
"description" varchar(255),
|
||||||
|
"hierarchyLevel" integer NOT NULL,
|
||||||
|
"isSystem" boolean DEFAULT false NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp,
|
||||||
|
CONSTRAINT "workspace_roles_publicId_unique" UNIQUE("publicId")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace_roles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace_members" ADD COLUMN "roleId" bigint;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "workspace_role_permissions" ADD CONSTRAINT "workspace_role_permissions_workspaceRoleId_workspace_roles_id_fk" FOREIGN KEY ("workspaceRoleId") REFERENCES "public"."workspace_roles"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "workspace_roles" ADD CONSTRAINT "workspace_roles_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "unique_member_permission" ON "workspace_member_permissions" USING btree ("workspaceMemberId","permission");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "permission_member_idx" ON "workspace_member_permissions" USING btree ("workspaceMemberId");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "unique_role_permission" ON "workspace_role_permissions" USING btree ("workspaceRoleId","permission");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "role_permissions_role_idx" ON "workspace_role_permissions" USING btree ("workspaceRoleId");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "unique_role_per_workspace" ON "workspace_roles" USING btree ("workspaceId","name");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_roles_workspace_idx" ON "workspace_roles" USING btree ("workspaceId");--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_roleId_workspace_roles_id_fk" FOREIGN KEY ("roleId") REFERENCES "public"."workspace_roles"("id") ON DELETE restrict ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Helper function to generate 12-character public IDs
|
||||||
|
CREATE OR REPLACE FUNCTION generate_public_id() RETURNS varchar(12) AS $$
|
||||||
|
DECLARE
|
||||||
|
chars text := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
result varchar(12) := '';
|
||||||
|
i integer;
|
||||||
|
BEGIN
|
||||||
|
FOR i IN 1..12 LOOP
|
||||||
|
result := result || substr(chars, floor(random() * length(chars) + 1)::integer, 1);
|
||||||
|
END LOOP;
|
||||||
|
RETURN result;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Seed system roles for each existing workspace
|
||||||
|
INSERT INTO "workspace_roles" ("publicId", "workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt")
|
||||||
|
SELECT generate_public_id(), w.id, 'admin', 'Full access to all workspace features', 100, true, NOW()
|
||||||
|
FROM "workspace" w
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM "workspace_roles" wr
|
||||||
|
WHERE wr."workspaceId" = w.id AND wr."name" = 'admin'
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "workspace_roles" ("publicId", "workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt")
|
||||||
|
SELECT generate_public_id(), w.id, 'member', 'Standard member with create and edit permissions', 50, true, NOW()
|
||||||
|
FROM "workspace" w
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM "workspace_roles" wr
|
||||||
|
WHERE wr."workspaceId" = w.id AND wr."name" = 'member'
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "workspace_roles" ("publicId", "workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt")
|
||||||
|
SELECT generate_public_id(), w.id, 'guest', 'View-only access', 10, true, NOW()
|
||||||
|
FROM "workspace" w
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM "workspace_roles" wr
|
||||||
|
WHERE wr."workspaceId" = w.id AND wr."name" = 'guest'
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Seed admin role permissions (all permissions)
|
||||||
|
INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt")
|
||||||
|
SELECT wr.id, p.permission, true, NOW()
|
||||||
|
FROM "workspace_roles" wr
|
||||||
|
CROSS JOIN (
|
||||||
|
VALUES
|
||||||
|
('workspace:view'), ('workspace:edit'), ('workspace:delete'), ('workspace:manage'),
|
||||||
|
('board:view'), ('board:create'), ('board:edit'), ('board:delete'),
|
||||||
|
('list:view'), ('list:create'), ('list:edit'), ('list:delete'),
|
||||||
|
('card:view'), ('card:create'), ('card:edit'), ('card:delete'),
|
||||||
|
('comment:view'), ('comment:create'), ('comment:edit'), ('comment:delete'),
|
||||||
|
('member:view'), ('member:invite'), ('member:edit'), ('member:remove')
|
||||||
|
) AS p(permission)
|
||||||
|
WHERE wr."name" = 'admin' AND wr."isSystem" = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "workspace_role_permissions" wrp
|
||||||
|
WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Seed member role permissions
|
||||||
|
INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt")
|
||||||
|
SELECT wr.id, p.permission, true, NOW()
|
||||||
|
FROM "workspace_roles" wr
|
||||||
|
CROSS JOIN (
|
||||||
|
VALUES
|
||||||
|
('workspace:view'),
|
||||||
|
('board:view'), ('board:create'),
|
||||||
|
('list:view'), ('list:create'), ('list:edit'), ('list:delete'),
|
||||||
|
('card:view'), ('card:create'), ('card:edit'), ('card:delete'),
|
||||||
|
('comment:view'), ('comment:create'), ('comment:edit'), ('comment:delete'),
|
||||||
|
('member:view')
|
||||||
|
) AS p(permission)
|
||||||
|
WHERE wr."name" = 'member' AND wr."isSystem" = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "workspace_role_permissions" wrp
|
||||||
|
WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Seed guest role permissions (view only)
|
||||||
|
INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt")
|
||||||
|
SELECT wr.id, p.permission, true, NOW()
|
||||||
|
FROM "workspace_roles" wr
|
||||||
|
CROSS JOIN (
|
||||||
|
VALUES
|
||||||
|
('workspace:view'),
|
||||||
|
('board:view'),
|
||||||
|
('list:view'),
|
||||||
|
('card:view'),
|
||||||
|
('comment:view'),
|
||||||
|
('member:view')
|
||||||
|
) AS p(permission)
|
||||||
|
WHERE wr."name" = 'guest' AND wr."isSystem" = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "workspace_role_permissions" wrp
|
||||||
|
WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Migrate existing workspace_members to use roleId
|
||||||
|
UPDATE "workspace_members" wm
|
||||||
|
SET "roleId" = wr.id
|
||||||
|
FROM "workspace_roles" wr
|
||||||
|
WHERE wm."workspaceId" = wr."workspaceId"
|
||||||
|
AND wm."role"::text = wr."name"
|
||||||
|
AND wm."roleId" IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Clean up helper function
|
||||||
|
DROP FUNCTION IF EXISTS generate_public_id();
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "user_board_favorites" (
|
||||||
|
"userId" uuid NOT NULL,
|
||||||
|
"boardId" bigint NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "user_board_favorites_userId_boardId_pk" PRIMARY KEY("userId","boardId")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "user_board_favorites" ADD CONSTRAINT "user_board_favorites_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "user_board_favorites" ADD CONSTRAINT "user_board_favorites_boardId_board_id_fk" FOREIGN KEY ("boardId") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "user_board_favorite_user_idx" ON "user_board_favorites" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "user_board_favorite_board_idx" ON "user_board_favorites" USING btree ("boardId");
|
||||||
2982
packages/db/migrations/meta/20260119164257_snapshot.json
Normal file
2982
packages/db/migrations/meta/20260119164257_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3313
packages/db/migrations/meta/20260126135909_snapshot.json
Normal file
3313
packages/db/migrations/meta/20260126135909_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3411
packages/db/migrations/meta/20260201215958_snapshot.json
Normal file
3411
packages/db/migrations/meta/20260201215958_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -162,6 +162,20 @@
|
|||||||
"when": 1768858977000,
|
"when": 1768858977000,
|
||||||
"tag": "20260119164257_AddShowEmailsToMembersToWorkspace",
|
"tag": "20260119164257_AddShowEmailsToMembersToWorkspace",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769435949476,
|
||||||
|
"tag": "20260126135909_AddWorkspaceRoles",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769983198190,
|
||||||
|
"tag": "20260201215958_AddUserBoardFavouritesTable",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
comments,
|
comments,
|
||||||
labels,
|
labels,
|
||||||
lists,
|
lists,
|
||||||
|
userBoardFavorites,
|
||||||
workspaceMembers,
|
workspaceMembers,
|
||||||
} from "@kan/db/schema";
|
} from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
@@ -39,17 +40,24 @@ export const getCount = async (db: dbClient) => {
|
|||||||
return result[0]?.count ?? 0;
|
return result[0]?.count ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllByWorkspaceId = (
|
export const getAllByWorkspaceId = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
workspaceId: number,
|
workspaceId: number,
|
||||||
|
userId: string,
|
||||||
opts?: { type?: "regular" | "template" },
|
opts?: { type?: "regular" | "template" },
|
||||||
) => {
|
) => {
|
||||||
return db.query.boards.findMany({
|
const boardsData = await db.query.boards.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
|
userFavorites: {
|
||||||
|
where: eq(userBoardFavorites.userId, userId),
|
||||||
|
columns: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
lists: {
|
lists: {
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
@@ -72,6 +80,21 @@ export const getAllByWorkspaceId = (
|
|||||||
opts?.type ? eq(boards.type, opts.type) : undefined,
|
opts?.type ? eq(boards.type, opts.type) : undefined,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Transform and sort: favorites first, then alphabetically
|
||||||
|
return boardsData
|
||||||
|
.map((board) => ({
|
||||||
|
...board,
|
||||||
|
favorite: board.userFavorites.length > 0,
|
||||||
|
userFavorites: undefined,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sort favorites first
|
||||||
|
if (a.favorite && !b.favorite) return -1;
|
||||||
|
if (!a.favorite && b.favorite) return 1;
|
||||||
|
// Then alphabetically by name
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
|
export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
|
||||||
@@ -122,6 +145,7 @@ const buildDueDateWhere = (filters: DueDateFilter[]) => {
|
|||||||
export const getByPublicId = async (
|
export const getByPublicId = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
boardPublicId: string,
|
boardPublicId: string,
|
||||||
|
userId: string,
|
||||||
filters: {
|
filters: {
|
||||||
members: string[];
|
members: string[];
|
||||||
labels: string[];
|
labels: string[];
|
||||||
@@ -173,6 +197,12 @@ export const getByPublicId = async (
|
|||||||
visibility: true,
|
visibility: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
|
userFavorites: {
|
||||||
|
where: eq(userBoardFavorites.userId, userId),
|
||||||
|
columns: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
@@ -325,6 +355,8 @@ export const getByPublicId = async (
|
|||||||
|
|
||||||
const formattedResult = {
|
const formattedResult = {
|
||||||
...board,
|
...board,
|
||||||
|
favorite: board.userFavorites.length > 0,
|
||||||
|
userFavorites: undefined,
|
||||||
lists: board.lists.map((list) => ({
|
lists: board.lists.map((list) => ({
|
||||||
...list,
|
...list,
|
||||||
cards: list.cards.map((card) => ({
|
cards: list.cards.map((card) => ({
|
||||||
@@ -518,6 +550,7 @@ export const getWithListIdsByPublicId = (
|
|||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
workspaceId: true,
|
workspaceId: true,
|
||||||
|
createdBy: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
lists: {
|
lists: {
|
||||||
@@ -671,6 +704,7 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
|
|||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
workspaceId: true,
|
workspaceId: true,
|
||||||
|
createdBy: true,
|
||||||
},
|
},
|
||||||
where: eq(boards.publicId, boardPublicId),
|
where: eq(boards.publicId, boardPublicId),
|
||||||
});
|
});
|
||||||
@@ -922,3 +956,34 @@ export const createFromSnapshot = async (
|
|||||||
return newBoard;
|
return newBoard;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const addUserFavorite = async (
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
boardId: number,
|
||||||
|
) => {
|
||||||
|
return db
|
||||||
|
.insert(userBoardFavorites)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
boardId,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeUserFavorite = async (
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
boardId: number,
|
||||||
|
) => {
|
||||||
|
return db
|
||||||
|
.delete(userBoardFavorites)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userBoardFavorites.userId, userId),
|
||||||
|
eq(userBoardFavorites.boardId, boardId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user