Compare commits
23 Commits
fix/react-
...
feat/custo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2f27a7e1 | ||
|
|
514692e200 | ||
|
|
247aa54a5c | ||
|
|
fc41d17bcb | ||
|
|
a2ef6609f2 | ||
|
|
2cb7c55d13 | ||
|
|
e3640542b3 | ||
|
|
62725ffa38 | ||
|
|
95bfe41910 | ||
|
|
3369092509 | ||
|
|
f210921d54 | ||
|
|
303ca1409f | ||
|
|
2a5e8c7651 | ||
|
|
0b734bd460 | ||
|
|
7239f958c0 | ||
|
|
3f81b9daa3 | ||
|
|
e86e59c817 | ||
|
|
090028d827 | ||
|
|
9415daecd3 | ||
|
|
087f7fe216 | ||
|
|
5f84f7b47d | ||
|
|
398a94b566 | ||
|
|
e026757fac |
@@ -37,7 +37,9 @@ checksums:
|
|||||||
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
|
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
|
||||||
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 +90,31 @@ 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: 78bfe1746f961f37b1cde85e5a0e9a13
|
||||||
|
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 +126,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 +143,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 +177,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 +221,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 +295,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 +357,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 +369,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 +402,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 +417,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 +430,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
|
||||||
@@ -429,12 +472,14 @@ checksums:
|
|||||||
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 +501,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 +536,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 +547,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 +566,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,6 +579,7 @@ 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%20URL/singular: 080746884059142358b58d9a44ff7d93
|
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
||||||
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
||||||
@@ -539,6 +591,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 +651,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 +662,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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}) {
|
}) {
|
||||||
@@ -33,10 +33,11 @@ export default function Dropdown({
|
|||||||
<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-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">
|
||||||
<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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
75
apps/web/src/hooks/usePermissions.ts
Normal file
75
apps/web/src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Permission } from "@kan/shared";
|
||||||
|
|
||||||
|
import { useWorkspace } 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 {
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
|
|
||||||
|
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
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} 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";
|
||||||
@@ -23,34 +24,48 @@ export default function BoardDropdown({
|
|||||||
workspacePublicId: string;
|
workspacePublicId: string;
|
||||||
}) {
|
}) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
return (
|
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
|
||||||
<Dropdown
|
|
||||||
disabled={isLoading}
|
const items = [
|
||||||
items={[
|
...(isTemplate
|
||||||
...(isTemplate
|
? []
|
||||||
? []
|
: [
|
||||||
: [
|
{
|
||||||
{
|
label: t`Make template`,
|
||||||
label: t`Make template`,
|
action: canCreateBoard ? () => openModal("CREATE_TEMPLATE") : undefined,
|
||||||
action: () => openModal("CREATE_TEMPLATE"),
|
icon: (
|
||||||
icon: (
|
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
),
|
||||||
),
|
disabled: !canCreateBoard,
|
||||||
},
|
},
|
||||||
{
|
...(canEditBoard
|
||||||
label: t`Edit board URL`,
|
? [
|
||||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
{
|
||||||
icon: <HiLink 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" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
|
|
||||||
{
|
...(canDeleteBoard
|
||||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
? [
|
||||||
action: () => openModal("DELETE_BOARD"),
|
{
|
||||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
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>
|
||||||
@@ -506,16 +518,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 +576,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
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { HiOutlineRectangleStack } from "react-icons/hi2";
|
|||||||
|
|
||||||
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,7 @@ 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 { data, isLoading } = api.board.all.useQuery(
|
const { data, isLoading } = api.board.all.useQuery(
|
||||||
{
|
{
|
||||||
@@ -41,9 +44,20 @@ 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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +47,8 @@ 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;
|
||||||
@@ -52,6 +57,9 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
cardPublicId: cardId ?? "",
|
cardPublicId: cardId ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
const workspaceMembers = board?.workspace.members;
|
const workspaceMembers = board?.workspace.members;
|
||||||
@@ -116,6 +124,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 +133,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 +143,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 +153,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 +174,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,
|
||||||
);
|
);
|
||||||
@@ -174,6 +188,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
cardPublicId: cardId ?? "",
|
cardPublicId: cardId ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 +315,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 +343,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 +372,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 +385,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 +394,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,16 +20,20 @@ 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 },
|
||||||
@@ -37,6 +42,31 @@ export default function MembersPage() {
|
|||||||
|
|
||||||
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 () => {
|
||||||
|
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 +97,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
|
||||||
@@ -127,19 +167,45 @@ 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>
|
||||||
)}
|
)}
|
||||||
@@ -153,6 +219,15 @@ export default function MembersPage() {
|
|||||||
{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 +241,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 +396,14 @@ export default function MembersPage() {
|
|||||||
>
|
>
|
||||||
<DeleteMemberConfirmation />
|
<DeleteMemberConfirmation />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
modalSize="sm"
|
||||||
|
isVisible={isOpen && modalContentType === "EDIT_MEMBER_PERMISSIONS"}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<EditMemberPermissionsModal />
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
101
apps/web/src/views/settings/PermissionsSettings.tsx
Normal file
101
apps/web/src/views/settings/PermissionsSettings.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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
|
||||||
|
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,6 +26,7 @@ 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);
|
||||||
@@ -61,6 +63,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 +73,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 +82,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 +90,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" &&
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -27,6 +29,7 @@ export default function UpdateWorkspaceEmailVisibilityForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
|
if (disabled) return;
|
||||||
const newValue = !isChecked;
|
const newValue = !isChecked;
|
||||||
setIsChecked(newValue);
|
setIsChecked(newValue);
|
||||||
updateWorkspace.mutate({
|
updateWorkspace.mutate({
|
||||||
@@ -46,7 +49,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)}
|
||||||
|
|||||||
@@ -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,7 +8,7 @@ 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 "../utils/s3";
|
||||||
|
|
||||||
export const attachmentRouter = createTRPCRouter({
|
export const attachmentRouter = createTRPCRouter({
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} 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,7 +58,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:view");
|
||||||
|
|
||||||
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id, {
|
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id, {
|
||||||
type: input.type,
|
type: input.type,
|
||||||
@@ -119,7 +119,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
|
||||||
@@ -255,7 +255,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) {
|
||||||
@@ -422,7 +422,13 @@ 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,
|
||||||
|
);
|
||||||
|
|
||||||
if (input.slug) {
|
if (input.slug) {
|
||||||
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
||||||
@@ -491,7 +497,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,7 +10,7 @@ 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 { generateDownloadUrl } from "../utils/s3";
|
||||||
|
|
||||||
export const cardRouter = createTRPCRouter({
|
export const cardRouter = createTRPCRouter({
|
||||||
@@ -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(
|
||||||
@@ -725,7 +725,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;
|
||||||
@@ -788,7 +788,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 +964,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, {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ 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";
|
||||||
|
|
||||||
export const workspaceRouter = createTRPCRouter({
|
export const workspaceRouter = createTRPCRouter({
|
||||||
all: protectedProcedure
|
all: protectedProcedure
|
||||||
@@ -74,8 +74,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,7 +83,8 @@ 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;
|
||||||
|
|
||||||
// If emails should be hidden, filter them out
|
// If emails should be hidden, filter them out
|
||||||
if (!shouldShowEmails) {
|
if (!shouldShowEmails) {
|
||||||
@@ -164,8 +164,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 +295,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 +377,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 +515,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",
|
||||||
|
});
|
||||||
|
}
|
||||||
172
packages/db/migrations/20260126135909_AddWorkspaceRoles.sql
Normal file
172
packages/db/migrations/20260126135909_AddWorkspaceRoles.sql
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
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
|
||||||
|
ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean DEFAULT true NOT NULL;--> 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();
|
||||||
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
@@ -162,6 +162,13 @@
|
|||||||
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -518,6 +518,7 @@ export const getWithListIdsByPublicId = (
|
|||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
workspaceId: true,
|
workspaceId: true,
|
||||||
|
createdBy: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
lists: {
|
lists: {
|
||||||
@@ -671,6 +672,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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ export const getWithListAndMembersByPublicId = async (
|
|||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
dueDate: true,
|
dueDate: true,
|
||||||
|
createdBy: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
labels: {
|
labels: {
|
||||||
@@ -938,7 +939,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
|||||||
cardPublicId: string,
|
cardPublicId: string,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query.cards.findFirst({
|
const result = await db.query.cards.findFirst({
|
||||||
columns: { id: true },
|
columns: { id: true, createdBy: true },
|
||||||
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
|
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
|
||||||
with: {
|
with: {
|
||||||
list: {
|
list: {
|
||||||
@@ -958,6 +959,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
|||||||
return result
|
return result
|
||||||
? {
|
? {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
|
createdBy: result.createdBy,
|
||||||
workspaceId: result.list.board.workspaceId,
|
workspaceId: result.list.board.workspaceId,
|
||||||
workspaceVisibility: result.list.board.visibility,
|
workspaceVisibility: result.list.board.visibility,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ export const getWorkspaceAndListIdByListPublicId = async (
|
|||||||
listPublicId: string,
|
listPublicId: string,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query.lists.findFirst({
|
const result = await db.query.lists.findFirst({
|
||||||
columns: { id: true },
|
columns: { id: true, createdBy: true },
|
||||||
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
|
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
|
||||||
with: {
|
with: {
|
||||||
board: {
|
board: {
|
||||||
@@ -431,6 +431,10 @@ export const getWorkspaceAndListIdByListPublicId = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
return result
|
return result
|
||||||
? { id: result.id, workspaceId: result.board.workspaceId }
|
? {
|
||||||
|
id: result.id,
|
||||||
|
createdBy: result.createdBy,
|
||||||
|
workspaceId: result.board.workspaceId,
|
||||||
|
}
|
||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const create = async (
|
|||||||
workspaceId: number;
|
workspaceId: number;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
role: MemberRole;
|
role: MemberRole;
|
||||||
|
roleId?: number | null;
|
||||||
status: MemberStatus;
|
status: MemberStatus;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
@@ -39,6 +40,7 @@ export const create = async (
|
|||||||
workspaceId: memberInput.workspaceId,
|
workspaceId: memberInput.workspaceId,
|
||||||
createdBy: memberInput.createdBy,
|
createdBy: memberInput.createdBy,
|
||||||
role: memberInput.role,
|
role: memberInput.role,
|
||||||
|
roleId: memberInput.roleId ?? null,
|
||||||
status: memberInput.status,
|
status: memberInput.status,
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
@@ -55,6 +57,12 @@ export const getByPublicId = async (db: dbClient, publicId: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getById = async (db: dbClient, memberId: number) => {
|
||||||
|
return db.query.workspaceMembers.findFirst({
|
||||||
|
where: eq(workspaceMembers.id, memberId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const getByEmailAndStatus = async (
|
export const getByEmailAndStatus = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
email: string,
|
email: string,
|
||||||
@@ -133,3 +141,28 @@ export const pauseAllMembers = async (db: dbClient, workspaceId: number) => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateRole = async (
|
||||||
|
db: dbClient,
|
||||||
|
args: {
|
||||||
|
memberId: number;
|
||||||
|
role: MemberRole;
|
||||||
|
roleId: number | null;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const [result] = await db
|
||||||
|
.update(workspaceMembers)
|
||||||
|
.set({
|
||||||
|
role: args.role,
|
||||||
|
roleId: args.roleId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(workspaceMembers.id, args.memberId))
|
||||||
|
.returning({
|
||||||
|
id: workspaceMembers.id,
|
||||||
|
publicId: workspaceMembers.publicId,
|
||||||
|
role: workspaceMembers.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|||||||
494
packages/db/src/repository/permission.repo.ts
Normal file
494
packages/db/src/repository/permission.repo.ts
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import { and, eq, isNull, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
import type { dbClient } from "@kan/db/client";
|
||||||
|
import {
|
||||||
|
workspaceMemberPermissions,
|
||||||
|
workspaceMembers,
|
||||||
|
workspaceRolePermissions,
|
||||||
|
workspaceRoles,
|
||||||
|
} from "@kan/db/schema";
|
||||||
|
import type { Permission, Role } from "@kan/shared";
|
||||||
|
import { generateUID, getDefaultPermissions } from "@kan/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get permissions by role ID
|
||||||
|
*/
|
||||||
|
export const getPermissionsByRoleId = async (
|
||||||
|
db: dbClient,
|
||||||
|
roleId: number,
|
||||||
|
): Promise<Permission[]> => {
|
||||||
|
const permissions = await db
|
||||||
|
.select({ permission: workspaceRolePermissions.permission })
|
||||||
|
.from(workspaceRolePermissions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceRolePermissions.workspaceRoleId, roleId),
|
||||||
|
eq(workspaceRolePermissions.granted, true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return permissions.map((p) => p.permission as Permission);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get role by workspace ID and name
|
||||||
|
*/
|
||||||
|
export const getRoleByWorkspaceIdAndName = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceId: number,
|
||||||
|
name: string,
|
||||||
|
) => {
|
||||||
|
const [role] = await db
|
||||||
|
.select()
|
||||||
|
.from(workspaceRoles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceRoles.workspaceId, workspaceId),
|
||||||
|
eq(workspaceRoles.name, name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return role;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get role by workspace ID and publicId
|
||||||
|
*/
|
||||||
|
export const getRoleByWorkspaceIdAndPublicId = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceId: number,
|
||||||
|
rolePublicId: string,
|
||||||
|
) => {
|
||||||
|
const [role] = await db
|
||||||
|
.select()
|
||||||
|
.from(workspaceRoles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceRoles.workspaceId, workspaceId),
|
||||||
|
eq(workspaceRoles.publicId, rolePublicId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return role;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all custom permission overrides for a workspace member
|
||||||
|
*/
|
||||||
|
export const getMemberPermissionOverrides = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceMemberId: number,
|
||||||
|
) => {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(workspaceMemberPermissions)
|
||||||
|
.where(eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single permission override for a member
|
||||||
|
*/
|
||||||
|
export const getMemberPermissionOverride = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceMemberId: number,
|
||||||
|
permission: string,
|
||||||
|
) => {
|
||||||
|
const [override] = await db
|
||||||
|
.select()
|
||||||
|
.from(workspaceMemberPermissions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId),
|
||||||
|
eq(workspaceMemberPermissions.permission, permission),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return override;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get effective permissions for a workspace member
|
||||||
|
* Combines role template (from DB) with custom overrides
|
||||||
|
*/
|
||||||
|
export const getMemberEffectivePermissions = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceMemberId: number,
|
||||||
|
roleId: number | null,
|
||||||
|
roleName: string,
|
||||||
|
): Promise<Permission[]> => {
|
||||||
|
let roleDefaults: Set<Permission>;
|
||||||
|
|
||||||
|
// Try to get role permissions from database first
|
||||||
|
if (roleId) {
|
||||||
|
const dbPermissions = await getPermissionsByRoleId(db, roleId);
|
||||||
|
roleDefaults = new Set<Permission>(dbPermissions);
|
||||||
|
} else {
|
||||||
|
// Fallback to code-based defaults if roleId not set
|
||||||
|
const codeDefaults = getDefaultPermissions(roleName as Role);
|
||||||
|
roleDefaults = new Set<Permission>([...codeDefaults]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get custom overrides
|
||||||
|
const overrides = await getMemberPermissionOverrides(db, workspaceMemberId);
|
||||||
|
|
||||||
|
// Apply overrides
|
||||||
|
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 const memberHasPermission = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceMemberId: number,
|
||||||
|
roleId: number | null,
|
||||||
|
roleName: string,
|
||||||
|
permission: Permission,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
let hasRoleDefault: boolean;
|
||||||
|
|
||||||
|
// Check role permission from database first
|
||||||
|
if (roleId) {
|
||||||
|
const dbPermissions = await getPermissionsByRoleId(db, roleId);
|
||||||
|
hasRoleDefault = dbPermissions.includes(permission);
|
||||||
|
} else {
|
||||||
|
// Fallback to code-based defaults
|
||||||
|
const codeDefaults = getDefaultPermissions(roleName as Role);
|
||||||
|
hasRoleDefault = codeDefaults.includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for override
|
||||||
|
const [override] = await db
|
||||||
|
.select()
|
||||||
|
.from(workspaceMemberPermissions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId),
|
||||||
|
eq(workspaceMemberPermissions.permission, permission),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Override takes precedence
|
||||||
|
if (override) {
|
||||||
|
return override.granted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasRoleDefault;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant a permission to a member
|
||||||
|
*/
|
||||||
|
export const grantPermission = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceMemberId: number,
|
||||||
|
permission: Permission,
|
||||||
|
) => {
|
||||||
|
const [result] = await db
|
||||||
|
.insert(workspaceMemberPermissions)
|
||||||
|
.values({
|
||||||
|
workspaceMemberId,
|
||||||
|
permission,
|
||||||
|
granted: true,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
workspaceMemberPermissions.workspaceMemberId,
|
||||||
|
workspaceMemberPermissions.permission,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
granted: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a permission from a member
|
||||||
|
*/
|
||||||
|
export const revokePermission = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceMemberId: number,
|
||||||
|
permission: Permission,
|
||||||
|
) => {
|
||||||
|
const [result] = await db
|
||||||
|
.insert(workspaceMemberPermissions)
|
||||||
|
.values({
|
||||||
|
workspaceMemberId,
|
||||||
|
permission,
|
||||||
|
granted: false,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
workspaceMemberPermissions.workspaceMemberId,
|
||||||
|
workspaceMemberPermissions.permission,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
granted: false,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all permission overrides for a workspace member
|
||||||
|
*/
|
||||||
|
export const clearMemberPermissionOverrides = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceMemberId: number,
|
||||||
|
) => {
|
||||||
|
await db
|
||||||
|
.delete(workspaceMemberPermissions)
|
||||||
|
.where(eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all permission overrides for all members in a workspace
|
||||||
|
*/
|
||||||
|
export const clearAllMemberPermissionOverridesForWorkspace = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceId: number,
|
||||||
|
) => {
|
||||||
|
const memberIds = await db
|
||||||
|
.select({ id: workspaceMembers.id })
|
||||||
|
.from(workspaceMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceMembers.workspaceId, workspaceId),
|
||||||
|
isNull(workspaceMembers.deletedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (memberIds.length === 0) return;
|
||||||
|
|
||||||
|
const ids = memberIds.map((m) => m.id);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(workspaceMemberPermissions)
|
||||||
|
.where(inArray(workspaceMemberPermissions.workspaceMemberId, ids));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get member with their role by userId and workspaceId
|
||||||
|
*/
|
||||||
|
export const getMemberWithRole = async (
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
workspaceId: number,
|
||||||
|
) => {
|
||||||
|
const [member] = await db
|
||||||
|
.select({
|
||||||
|
id: workspaceMembers.id,
|
||||||
|
publicId: workspaceMembers.publicId,
|
||||||
|
role: workspaceMembers.role,
|
||||||
|
roleId: workspaceMembers.roleId,
|
||||||
|
})
|
||||||
|
.from(workspaceMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceMembers.userId, userId),
|
||||||
|
eq(workspaceMembers.workspaceId, workspaceId),
|
||||||
|
isNull(workspaceMembers.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return member;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has permission in workspace
|
||||||
|
*/
|
||||||
|
export const userHasPermissionInWorkspace = async (
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
workspaceId: number,
|
||||||
|
permission: Permission,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const member = await 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 const getUserPermissionsInWorkspace = async (
|
||||||
|
db: dbClient,
|
||||||
|
userId: string,
|
||||||
|
workspaceId: number,
|
||||||
|
): Promise<{
|
||||||
|
permissions: Permission[];
|
||||||
|
role: string;
|
||||||
|
roleId: number | null;
|
||||||
|
} | null> => {
|
||||||
|
const member = await 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a role for a workspace
|
||||||
|
*/
|
||||||
|
export const createRole = async (
|
||||||
|
db: dbClient,
|
||||||
|
args: {
|
||||||
|
workspaceId: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
hierarchyLevel: number;
|
||||||
|
isSystem: boolean;
|
||||||
|
permissions: Permission[];
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const [role] = await db
|
||||||
|
.insert(workspaceRoles)
|
||||||
|
.values({
|
||||||
|
publicId: generateUID(),
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
name: args.name,
|
||||||
|
description: args.description,
|
||||||
|
hierarchyLevel: args.hierarchyLevel,
|
||||||
|
isSystem: args.isSystem,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: workspaceRoles.id,
|
||||||
|
name: workspaceRoles.name,
|
||||||
|
publicId: workspaceRoles.publicId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (role && args.permissions.length > 0) {
|
||||||
|
await db.insert(workspaceRolePermissions).values(
|
||||||
|
args.permissions.map((perm) => ({
|
||||||
|
workspaceRoleId: role.id,
|
||||||
|
permission: perm,
|
||||||
|
granted: true,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return role;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant a permission to a role
|
||||||
|
*/
|
||||||
|
export const grantRolePermission = async (
|
||||||
|
db: dbClient,
|
||||||
|
roleId: number,
|
||||||
|
permission: Permission,
|
||||||
|
) => {
|
||||||
|
const [result] = await db
|
||||||
|
.insert(workspaceRolePermissions)
|
||||||
|
.values({
|
||||||
|
workspaceRoleId: roleId,
|
||||||
|
permission,
|
||||||
|
granted: true,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
workspaceRolePermissions.workspaceRoleId,
|
||||||
|
workspaceRolePermissions.permission,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a permission from a role
|
||||||
|
*/
|
||||||
|
export const revokeRolePermission = async (
|
||||||
|
db: dbClient,
|
||||||
|
roleId: number,
|
||||||
|
permission: Permission,
|
||||||
|
) => {
|
||||||
|
const [result] = await db
|
||||||
|
.insert(workspaceRolePermissions)
|
||||||
|
.values({
|
||||||
|
workspaceRoleId: roleId,
|
||||||
|
permission,
|
||||||
|
granted: false,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
workspaceRolePermissions.workspaceRoleId,
|
||||||
|
workspaceRolePermissions.permission,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all roles for a workspace
|
||||||
|
*/
|
||||||
|
export const getRolesByWorkspaceId = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceId: number,
|
||||||
|
) => {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: workspaceRoles.id,
|
||||||
|
publicId: workspaceRoles.publicId,
|
||||||
|
name: workspaceRoles.name,
|
||||||
|
description: workspaceRoles.description,
|
||||||
|
hierarchyLevel: workspaceRoles.hierarchyLevel,
|
||||||
|
isSystem: workspaceRoles.isSystem,
|
||||||
|
})
|
||||||
|
.from(workspaceRoles)
|
||||||
|
.where(eq(workspaceRoles.workspaceId, workspaceId));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +18,33 @@ import {
|
|||||||
workspaceMembers,
|
workspaceMembers,
|
||||||
workspaces,
|
workspaces,
|
||||||
} from "@kan/db/schema";
|
} from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import type { Permission, Role } from "@kan/shared";
|
||||||
|
import { generateUID, getDefaultPermissions } from "@kan/shared";
|
||||||
|
|
||||||
|
import * as permissionRepo from "./permission.repo";
|
||||||
|
|
||||||
|
// System role definitions
|
||||||
|
const SYSTEM_ROLES: {
|
||||||
|
name: Role;
|
||||||
|
description: string;
|
||||||
|
hierarchyLevel: number;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
name: "admin",
|
||||||
|
description: "Full access to all workspace features",
|
||||||
|
hierarchyLevel: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "member",
|
||||||
|
description: "Standard member with create and edit permissions",
|
||||||
|
hierarchyLevel: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "guest",
|
||||||
|
description: "View-only access",
|
||||||
|
hierarchyLevel: 10,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
export const getCount = async (db: dbClient) => {
|
||||||
const result = await db
|
const result = await db
|
||||||
@@ -57,6 +83,22 @@ export const create = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
|
// Create system roles for the workspace
|
||||||
|
let adminRoleId: number | null = null;
|
||||||
|
for (const roleData of SYSTEM_ROLES) {
|
||||||
|
const role = await permissionRepo.createRole(db, {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
name: roleData.name,
|
||||||
|
description: roleData.description,
|
||||||
|
hierarchyLevel: roleData.hierarchyLevel,
|
||||||
|
isSystem: true,
|
||||||
|
permissions: [...getDefaultPermissions(roleData.name)] as Permission[],
|
||||||
|
});
|
||||||
|
if (roleData.name === "admin" && role) {
|
||||||
|
adminRoleId = role.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.insert(workspaceMembers).values({
|
await db.insert(workspaceMembers).values({
|
||||||
publicId: generateUID(),
|
publicId: generateUID(),
|
||||||
userId: workspaceInput.createdBy,
|
userId: workspaceInput.createdBy,
|
||||||
@@ -64,6 +106,7 @@ export const create = async (
|
|||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
createdBy: workspaceInput.createdBy,
|
createdBy: workspaceInput.createdBy,
|
||||||
role: "admin",
|
role: "admin",
|
||||||
|
roleId: adminRoleId,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -151,8 +194,13 @@ export const getByPublicIdWithMembers = (
|
|||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
},
|
},
|
||||||
where: isNull(workspaceMembers.deletedAt),
|
where: isNull(workspaceMembers.deletedAt),
|
||||||
|
orderBy: (member, { desc }) => [
|
||||||
|
desc(sql`CASE WHEN ${member.role} = 'admin' THEN 1 ELSE 0 END`),
|
||||||
|
desc(member.createdAt),
|
||||||
|
],
|
||||||
with: {
|
with: {
|
||||||
user: {
|
user: {
|
||||||
columns: {
|
columns: {
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ export * from "./integrations";
|
|||||||
export * from "./workspaces";
|
export * from "./workspaces";
|
||||||
export * from "./subscriptions";
|
export * from "./subscriptions";
|
||||||
export * from "./workspaceInviteLinks";
|
export * from "./workspaceInviteLinks";
|
||||||
|
export * from "./permissions";
|
||||||
|
|||||||
98
packages/db/src/schema/permissions.ts
Normal file
98
packages/db/src/schema/permissions.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
bigserial,
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
pgTable,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import { workspaces } from "./workspaces";
|
||||||
|
|
||||||
|
export const workspaceRoles = pgTable(
|
||||||
|
"workspace_roles",
|
||||||
|
{
|
||||||
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
|
workspaceId: bigint("workspaceId", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||||
|
name: varchar("name", { length: 64 }).notNull(),
|
||||||
|
description: varchar("description", { length: 255 }),
|
||||||
|
hierarchyLevel: integer("hierarchyLevel").notNull(),
|
||||||
|
isSystem: boolean("isSystem").notNull().default(false),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("unique_role_per_workspace").on(table.workspaceId, table.name),
|
||||||
|
index("workspace_roles_workspace_idx").on(table.workspaceId),
|
||||||
|
],
|
||||||
|
).enableRLS();
|
||||||
|
|
||||||
|
export const workspaceRolesRelations = relations(
|
||||||
|
workspaceRoles,
|
||||||
|
({ one, many }) => ({
|
||||||
|
workspace: one(workspaces, {
|
||||||
|
fields: [workspaceRoles.workspaceId],
|
||||||
|
references: [workspaces.id],
|
||||||
|
relationName: "workspaceRoles",
|
||||||
|
}),
|
||||||
|
permissions: many(workspaceRolePermissions),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
export const workspaceRolePermissions = pgTable(
|
||||||
|
"workspace_role_permissions",
|
||||||
|
{
|
||||||
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
|
workspaceRoleId: bigint("workspaceRoleId", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => workspaceRoles.id, { onDelete: "cascade" }),
|
||||||
|
permission: varchar("permission", { length: 64 }).notNull(),
|
||||||
|
granted: boolean("granted").notNull().default(true),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("unique_role_permission").on(
|
||||||
|
table.workspaceRoleId,
|
||||||
|
table.permission,
|
||||||
|
),
|
||||||
|
index("role_permissions_role_idx").on(table.workspaceRoleId),
|
||||||
|
],
|
||||||
|
).enableRLS();
|
||||||
|
|
||||||
|
export const workspaceRolePermissionsRelations = relations(
|
||||||
|
workspaceRolePermissions,
|
||||||
|
({ one }) => ({
|
||||||
|
role: one(workspaceRoles, {
|
||||||
|
fields: [workspaceRolePermissions.workspaceRoleId],
|
||||||
|
references: [workspaceRoles.id],
|
||||||
|
relationName: "rolePermissions",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const workspaceMemberPermissions = pgTable(
|
||||||
|
"workspace_member_permissions",
|
||||||
|
{
|
||||||
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
|
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" }).notNull(),
|
||||||
|
permission: varchar("permission", { length: 64 }).notNull(),
|
||||||
|
granted: boolean("granted").notNull().default(true),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("unique_member_permission").on(
|
||||||
|
table.workspaceMemberId,
|
||||||
|
table.permission,
|
||||||
|
),
|
||||||
|
index("permission_member_idx").on(table.workspaceMemberId),
|
||||||
|
],
|
||||||
|
).enableRLS();
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
import { boards } from "./boards";
|
import { boards } from "./boards";
|
||||||
|
import { workspaceMemberPermissions, workspaceRoles } from "./permissions";
|
||||||
import { subscription } from "./subscriptions";
|
import { subscription } from "./subscriptions";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
|
|||||||
members: many(workspaceMembers),
|
members: many(workspaceMembers),
|
||||||
boards: many(boards),
|
boards: many(boards),
|
||||||
subscriptions: many(subscription),
|
subscriptions: many(subscription),
|
||||||
|
roles: many(workspaceRoles),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const workspaceMembers = pgTable("workspace_members", {
|
export const workspaceMembers = pgTable("workspace_members", {
|
||||||
@@ -86,13 +88,18 @@ export const workspaceMembers = pgTable("workspace_members", {
|
|||||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
// Legacy role enum
|
||||||
role: memberRoleEnum("role").notNull(),
|
role: memberRoleEnum("role").notNull(),
|
||||||
|
roleId: bigint("roleId", { mode: "number" }).references(
|
||||||
|
() => workspaceRoles.id,
|
||||||
|
{ onDelete: "restrict" },
|
||||||
|
),
|
||||||
status: memberStatusEnum("status").default("invited").notNull(),
|
status: memberStatusEnum("status").default("invited").notNull(),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
export const workspaceMembersRelations = relations(
|
export const workspaceMembersRelations = relations(
|
||||||
workspaceMembers,
|
workspaceMembers,
|
||||||
({ one }) => ({
|
({ one, many }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [workspaceMembers.userId],
|
fields: [workspaceMembers.userId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
@@ -103,6 +110,23 @@ export const workspaceMembersRelations = relations(
|
|||||||
references: [workspaces.id],
|
references: [workspaces.id],
|
||||||
relationName: "workspaceMembersWorkspace",
|
relationName: "workspaceMembersWorkspace",
|
||||||
}),
|
}),
|
||||||
|
workspaceRole: one(workspaceRoles, {
|
||||||
|
fields: [workspaceMembers.roleId],
|
||||||
|
references: [workspaceRoles.id],
|
||||||
|
relationName: "workspaceMemberRole",
|
||||||
|
}),
|
||||||
|
permissions: many(workspaceMemberPermissions),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const workspaceMemberPermissionsRelations = relations(
|
||||||
|
workspaceMemberPermissions,
|
||||||
|
({ one }) => ({
|
||||||
|
member: one(workspaceMembers, {
|
||||||
|
fields: [workspaceMemberPermissions.workspaceMemberId],
|
||||||
|
references: [workspaceMembers.id],
|
||||||
|
relationName: "memberPermissions",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export const name = "shared";
|
|||||||
|
|
||||||
export * from "./constants";
|
export * from "./constants";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
|
export * from "./permissions";
|
||||||
|
|||||||
165
packages/shared/src/permissions.ts
Normal file
165
packages/shared/src/permissions.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
export const permissionActions = [
|
||||||
|
"view",
|
||||||
|
"create",
|
||||||
|
"edit",
|
||||||
|
"delete",
|
||||||
|
"manage",
|
||||||
|
] as const;
|
||||||
|
export type PermissionAction = (typeof permissionActions)[number];
|
||||||
|
|
||||||
|
export const permissionResources = [
|
||||||
|
"workspace",
|
||||||
|
"board",
|
||||||
|
"list",
|
||||||
|
"card",
|
||||||
|
"comment",
|
||||||
|
"member",
|
||||||
|
] as const;
|
||||||
|
export type PermissionResource = (typeof permissionResources)[number];
|
||||||
|
|
||||||
|
export const allPermissions = [
|
||||||
|
"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 const;
|
||||||
|
|
||||||
|
export type Permission = (typeof allPermissions)[number];
|
||||||
|
|
||||||
|
export const roleHierarchy = {
|
||||||
|
admin: 100,
|
||||||
|
member: 50,
|
||||||
|
guest: 10,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Role = keyof typeof roleHierarchy;
|
||||||
|
export const roles = Object.keys(roleHierarchy) as Role[];
|
||||||
|
|
||||||
|
export const defaultRolePermissions: Record<Role, readonly Permission[]> = {
|
||||||
|
admin: allPermissions,
|
||||||
|
member: [
|
||||||
|
"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",
|
||||||
|
],
|
||||||
|
|
||||||
|
guest: [
|
||||||
|
"workspace:view",
|
||||||
|
"board:view",
|
||||||
|
"list:view",
|
||||||
|
"card:view",
|
||||||
|
"comment:view",
|
||||||
|
"member:view",
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
|
export const permissionCategories = {
|
||||||
|
workspace: {
|
||||||
|
label: "Workspace",
|
||||||
|
permissions: [
|
||||||
|
"workspace:view",
|
||||||
|
"workspace:edit",
|
||||||
|
"workspace:delete",
|
||||||
|
"workspace:manage",
|
||||||
|
] as const,
|
||||||
|
},
|
||||||
|
board: {
|
||||||
|
label: "Boards",
|
||||||
|
permissions: [
|
||||||
|
"board:view",
|
||||||
|
"board:create",
|
||||||
|
"board:edit",
|
||||||
|
"board:delete",
|
||||||
|
] as const,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
label: "Lists",
|
||||||
|
permissions: [
|
||||||
|
"list:view",
|
||||||
|
"list:create",
|
||||||
|
"list:edit",
|
||||||
|
"list:delete",
|
||||||
|
] as const,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
label: "Cards",
|
||||||
|
permissions: [
|
||||||
|
"card:view",
|
||||||
|
"card:create",
|
||||||
|
"card:edit",
|
||||||
|
"card:delete",
|
||||||
|
] as const,
|
||||||
|
},
|
||||||
|
comment: {
|
||||||
|
label: "Comments",
|
||||||
|
permissions: [
|
||||||
|
"comment:view",
|
||||||
|
"comment:create",
|
||||||
|
"comment:edit",
|
||||||
|
"comment:delete",
|
||||||
|
] as const,
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
label: "Members",
|
||||||
|
permissions: [
|
||||||
|
"member:view",
|
||||||
|
"member:invite",
|
||||||
|
"member:edit",
|
||||||
|
"member:remove",
|
||||||
|
] as const,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getDefaultPermissions(role: Role): readonly Permission[] {
|
||||||
|
return defaultRolePermissions[role];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoleLevel(role: Role): number {
|
||||||
|
return roleHierarchy[role];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canManageRole(managerRole: Role, targetRole: Role): boolean {
|
||||||
|
return roleHierarchy[managerRole] >= roleHierarchy[targetRole];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPermissionInDefaults(
|
||||||
|
role: Role,
|
||||||
|
permission: Permission,
|
||||||
|
): boolean {
|
||||||
|
return defaultRolePermissions[role].includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user