Compare commits

...

23 Commits

Author SHA1 Message Date
Henry
0a2f27a7e1 chore: translations 2026-02-01 21:03:32 +00:00
Henry
514692e200 refactor: centre permissions modal 2026-02-01 20:53:08 +00:00
Henry
247aa54a5c feat: allow delete:list as default 2026-01-30 14:55:21 +00:00
Henry
fc41d17bcb feat: allow admins to assign the admin role to other users 2026-01-29 22:07:50 +00:00
Henry
a2ef6609f2 feat: disable UI elements if user does not have permissions 2026-01-29 22:05:00 +00:00
Henry
2cb7c55d13 feat: set roleId when inviting new members 2026-01-29 21:07:50 +00:00
Henry
e3640542b3 feat: allow users to delete entities they have created 2026-01-29 20:50:38 +00:00
Henry
62725ffa38 feat: clear all permission overrides 2026-01-28 23:18:44 +00:00
Henry
95bfe41910 feat: reset role defaults 2026-01-28 23:00:10 +00:00
Henry
3369092509 feat: allow editing individual permissions 2026-01-28 22:42:04 +00:00
Henry
f210921d54 feat: order members by role and createdAt 2026-01-28 21:45:19 +00:00
Henry
303ca1409f feat: enable updating member roles 2026-01-28 21:32:46 +00:00
Henry
2a5e8c7651 feat: add permissions page to settings 2026-01-28 20:11:50 +00:00
Henry
0b734bd460 feat: assert permission for remaining routes 2026-01-28 12:03:21 +00:00
Henry
7239f958c0 feat: assert board permissions 2026-01-26 17:23:03 +00:00
Henry
3f81b9daa3 feat: assert permissions for lists 2026-01-26 16:53:58 +00:00
Henry
e86e59c817 feat: add card permissions 2026-01-26 16:44:53 +00:00
Henry
090028d827 feat: setup basic router interactions 2026-01-26 14:45:40 +00:00
Henry
9415daecd3 feat: add repo funcs 2026-01-26 14:38:44 +00:00
Henry
087f7fe216 feat: setup default permissions 2026-01-26 14:34:45 +00:00
Henry
5f84f7b47d feat: add publicId to workspace roles 2026-01-26 14:05:24 +00:00
Henry
398a94b566 chore: regen migration 2026-01-26 13:40:52 +00:00
Henry
e026757fac feat: setup schema for workspace roles 2026-01-26 13:23:25 +00:00
83 changed files with 10568 additions and 1442 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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}

View File

@@ -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>
)} )}

View File

@@ -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>

View 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

View File

@@ -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>
); );
}; };

View File

@@ -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>
); );
}; };

View File

@@ -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>
); );
}; };

View 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;

View File

@@ -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>
); );
}; };

View File

@@ -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}
items={[
...(isTemplate
? []
: [
{
label: t`Make template`,
action: () => openModal("CREATE_TEMPLATE"),
icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
),
},
{
label: t`Edit board URL`,
action: () => openModal("UPDATE_BOARD_SLUG"),
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
]),
{ const items = [
label: isTemplate ? t`Delete template` : t`Delete board`, ...(isTemplate
action: () => openModal("DELETE_BOARD"), ? []
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />, : [
}, {
]} label: t`Make template`,
> action: canCreateBoard ? () => openModal("CREATE_TEMPLATE") : undefined,
icon: (
<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" />,
},
]
: []),
]),
...(canDeleteBoard
? [
{
label: isTemplate ? t`Delete template` : t`Delete board`,
action: () => openModal("DELETE_BOARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
},
]
: []),
];
if (items.length === 0) {
return null;
}
return (
<Dropdown disabled={isLoading} items={items}>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" /> <HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown> </Dropdown>
); );

View File

@@ -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}

View File

@@ -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;

View File

@@ -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>
); );
}; };

View File

@@ -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

View File

@@ -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>
); );

View File

@@ -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" />
} }

View File

@@ -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`,

View File

@@ -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>
); );

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }) => (

View File

@@ -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)}

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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>
</> </>

View 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>
</>
);
}

View File

@@ -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" &&

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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)}

View File

@@ -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>
); );

View File

@@ -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)}

View File

@@ -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)}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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();

View File

@@ -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, {

View File

@@ -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",

View File

@@ -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);

View File

@@ -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;

View File

@@ -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,
};
}),
}); });

View 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 };
}),
});

View File

@@ -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,

View 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",
});
}

View 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();

File diff suppressed because it is too large Load Diff

View File

@@ -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
} }
] ]
} }

View File

@@ -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),
}); });

View File

@@ -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,
} }

View File

@@ -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;
}; };

View File

@@ -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;
};

View 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));
};

View File

@@ -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: {

View File

@@ -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";

View 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();

View File

@@ -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",
}),
}), }),
); );

View File

@@ -2,3 +2,4 @@ export const name = "shared";
export * from "./constants"; export * from "./constants";
export * from "./utils"; export * from "./utils";
export * from "./permissions";

View 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);
}