Compare commits

...

10 Commits

Author SHA1 Message Date
Henry
de87f6237e fix: prevent ellipses from showing through member dropdown menu 2026-02-02 20:03:52 +00:00
Henry
2269d23c94 fix: return empty permissions if WorkspaceProvider is not available 2026-02-01 23:23:06 +00:00
Henry
e437d075e3 fix: remove duplicate statement 2026-02-01 22:58:57 +00:00
Henry
b422907b53 fix: skip new col if already exists 2026-02-01 22:52:28 +00:00
Henry
672dfe6540 fix: ensure publicId is returned from create card (#350) 2026-02-01 22:42:13 +00:00
Eliott Herbert-Byrnes
ef5bf87fdf feat: add board favourites with animations (#318)
* feat: add board favorites with animations

* feat: add board favorites functionality with admin-only access

* refactor: make board favorites user-specific via junction table

* fix: assertUserInWorkspace

* Update pnpm lockfile to match package.json

* chore: clean up migrations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-02-01 22:29:51 +00:00
Nick Meinhold
ec37f5480a fix(api): include name and slug in workspace byId response (#347)
The GET /workspaces/{id} endpoint was missing the workspace's name and
slug fields because they weren't included in the column selection.

Fixes #346

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 22:29:37 +00:00
Henry
7f5a1ab513 feat: customisable workspace role permissions (#345)
* feat: setup schema for workspace roles

* chore: regen migration

* feat: add publicId to workspace roles

* feat: setup default permissions

* feat: add repo funcs

* feat: setup basic router interactions

* feat: add card permissions

* feat: assert permissions for lists

* feat: assert board permissions

* feat: assert permission for remaining routes

* feat: add permissions page to settings

* feat: enable updating member roles

* feat: order members by role and createdAt

* feat: allow editing individual permissions

* feat: reset role defaults

* feat: clear all permission overrides

* feat: allow users to delete entities they have created

* feat: set roleId when inviting new members

* feat: disable UI elements if user does not have permissions

* feat: allow admins to assign the admin role to other users

* feat: allow delete:list as default

* refactor: centre permissions modal

* chore: translations
2026-02-01 21:17:18 +00:00
Henry
5f2d409773 fix: allow empty REDIS_URL in environment configuration 2026-01-26 11:44:17 +00:00
Henry
210e44db5c feat: add env var to use virtual hosted urls (#338) 2026-01-26 10:16:42 +00:00
101 changed files with 17370 additions and 1480 deletions

View File

@@ -32,6 +32,7 @@ NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN=
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
# Auth config (optional)
NEXT_PUBLIC_ALLOW_CREDENTIALS=

View File

@@ -173,6 +173,7 @@ pnpm dev
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |

View File

@@ -37,7 +37,9 @@ checksums:
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
Adjust%20the%20square%20crop%20to%20fit%20your%20avatar./singular: a4df26bbce6f14c6962fac1324db00a8
Admin/singular: 90eb20f1400db82ab874744e47836dc6
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
All%20member%20permission%20overrides%20have%20been%20reset%20to%20their%20role%20defaults./singular: e5c38724a283373506d53afd22b6d096
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
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
@@ -88,6 +90,31 @@ checksums:
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
Bug/singular: 4509fffdb5931f8905063c80cf802d71
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
Card/singular: bba0beaced7ea954ceb980f2b022ffee
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
@@ -99,6 +126,9 @@ checksums:
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
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
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
Close/singular: 2c2e22f8424a1031de89063bd0022e16
@@ -113,6 +143,7 @@ checksums:
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
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%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
@@ -146,6 +177,7 @@ checksums:
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
Custom%20board%20templates/singular: c2966b352d76bc53421c01e9c99474bb
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
Custom%20permissions/singular: 6f1748601979e2e4548877b292b43a20
Custom%20templates/singular: f8caaad67e168f106a298c8e0a66240c
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
Custom%20workspace%20link/singular: 8a19ae46ccea9c54b65ae183cea70b44
@@ -189,6 +221,7 @@ checksums:
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
Edit%20permissions/singular: 244558dd716491b7ed72ba8ab73aa28f
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
@@ -262,6 +295,7 @@ checksums:
Go%20to%20members/singular: 445f4efbc4b1e7509f4fd79ebfbb1476
Go%20to%20settings/singular: 24a7f96880650c9b37099d69f4b7e2a9
Go%20to%20templates/singular: e4e58e33d637282d141df466d729bc7c
Guest/singular: 2aec6d6ebe0d9a1db0a5c8cd5a98b8c3
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
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
Lists/singular: 9f4a73afc8de321175d71935134ef066
Load%20more%20activities/singular: f32d40a739ffaa700051c4c7d70055cf
Loading%20permissions.../singular: a5665279d4e439186825057c32d4d976
Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
@@ -334,6 +369,7 @@ checksums:
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
Member/singular: 1606dc30b369856b9dba1fe9aec425d2
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
Monthly/singular: 818f1192e32bb855597f930d3e78806e
@@ -366,7 +402,9 @@ checksums:
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
No%20keyboard%20shortcuts%20registered./singular: 7f1ed5d777cade7d62303e9e591bbf63
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
No%20lists%20have%20been%20created%20yet/singular: f18ee3d7230cc33b68bd17b429d1d442
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
No%20roles%20found%20for%20this%20workspace%20yet./singular: 2eb502333aaf6c58e8ab23d881e2e357
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
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
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
Overdue/singular: 24caaa2b5d7a2447ab7664e3771cf98c
Overrides%20cleared/singular: f2e380efbae31a6113cc0cbf0eadf7b0
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
@@ -391,6 +430,10 @@ checksums:
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
Permission/singular: cc2ed7274bd8267f9e0a10b079584d8b
Permissions/singular: 2160be68b1d6b6577e64634e9feba2ed
Permissions%20reset/singular: dd4776f04aca858deb95887e807570fc
Permissions%20updated/singular: 0df44570b783b8b284610da8627d333d
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
Planning/singular: 353f58c75248275fe091740607501610
Platform/singular: c68862170146325333c7f25af11a3fa2
@@ -429,12 +472,14 @@ checksums:
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
Research/singular: 3368e9638d1619babd6df9fad592274f
Reset%20to%20role%20defaults/singular: fc9ff8ea0503e50da3e9b3e5d8bb75dd
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
Resources/singular: ec7fb05ed963bb6781a35782b3475502
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
Review/singular: 299f75db25382980b2895622d7712927
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
Role/singular: 53743bbb6ca938f5b893552e839d067f
Role%20updated/singular: 73606ae9c35101f1bb518961f68559d0
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
@@ -456,6 +501,7 @@ checksums:
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
Settings%20%7C%20Permissions/singular: 8aa60ed978b9f45a705d99dc1ee04f37
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
Shortcuts/singular: db3330ed3240c398054f3be23c52851f
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
@@ -490,6 +536,8 @@ checksums:
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
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%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%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
Theme/singular: 21fe00b7a518089576fb83c08631107a
@@ -499,6 +547,8 @@ checksums:
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
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%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
@@ -516,6 +566,7 @@ checksums:
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
Unable%20to%20add%20label/singular: b09fda6420ea1dc10dbea0b1dc373f29
Unable%20to%20clear%20overrides/singular: dfeac280858332082ad34d2623a59863
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
@@ -528,6 +579,7 @@ checksums:
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
Unable%20to%20reorder%20checklist%20item/singular: dcc238c72b85daf74ebd46adcd914473
Unable%20to%20reset%20permissions/singular: fb09d88ff4eb32afb852d733bafd515b
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
@@ -539,6 +591,8 @@ checksums:
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
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%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
@@ -597,6 +651,7 @@ checksums:
Workspace%20name%20is%20required/singular: b8c5162dd08c4d941bc57f9d0cbee451
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
Workspace%20permissions/singular: 72c0202f30e543eb81bf930d85647096
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
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%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%20don't%20have%20permission/singular: 11d928b1993d95d54a95f85f8ae5016d
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
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%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./singular: 24fc6cdc8740f37a83df85f582f03293
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf

View File

@@ -47,6 +47,7 @@
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"date-fns": "^4.1.0",
"framer-motion": "^12.26.2",
"geist": "^1.3.1",
"jose": "^6.1.2",
"next": "15.5.9",

View File

@@ -31,6 +31,7 @@ interface CheckboxDropdownProps {
handleEdit?: (key: string) => void;
handleCreate?: () => void;
asChild?: boolean;
disabled?: boolean;
}
export default function CheckboxDropdown({
@@ -44,6 +45,7 @@ export default function CheckboxDropdown({
handleEdit,
handleCreate,
asChild = true,
disabled = false,
}: CheckboxDropdownProps) {
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
@@ -58,13 +60,13 @@ export default function CheckboxDropdown({
{items.length > 0 ? (
items.map((item) => (
<Menu.Item key={item.key}>
<div
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(groupKey, { key: item.key, value: item.value });
}}
>
<div
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(groupKey, { key: item.key, value: item.value });
}}
>
<input
id={item.key}
name={item.key}
@@ -132,7 +134,8 @@ export default function CheckboxDropdown({
<>
<Menu.Button
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}
</Menu.Button>

View File

@@ -6,7 +6,7 @@ export default function Dropdown({
children,
disabled,
}: {
items: { label: string; action: () => void; icon?: React.ReactNode }[];
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
children: React.ReactNode;
disabled?: boolean;
}) {
@@ -30,13 +30,14 @@ export default function Dropdown({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<div className="flex flex-col">
{items.map((item) => (
<Menu.Item key={item.label}>
<Menu.Item key={item.label} disabled={item.disabled}>
<button
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.label}

View File

@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
return (
<div
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">
<Transition
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
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="p-4">
<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 relative">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="flex-shrink-0 mt-1">
{popupIcon === "success" && (
<HiOutlineCheckCircle
aria-hidden="true"
className="h-6 w-6 text-green-400"
className="h-5 w-5 text-green-400"
/>
)}
{popupIcon === "error" && (
<HiOutlineExclamationCircle
aria-hidden="true"
className="h-6 w-6 text-red-400"
className="h-5 w-5 text-red-400"
/>
)}
</div>
<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}
</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}
</p>
</div>
<div className="ml-4 flex flex-shrink-0">
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
<button
type="button"
onClick={() => {
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>
<HiXMark
aria-hidden="true"
className="h-5 w-5 text-dark-900"
className="h-4 w-4 text-dark-900"
/>
</button>
</div>

View File

@@ -14,8 +14,11 @@ import {
HiOutlineBanknotes,
HiOutlineCodeBracketSquare,
HiOutlineRectangleGroup,
HiOutlineShieldCheck,
HiOutlineUser,
} from "react-icons/hi2";
import { usePermissions } from "~/hooks/usePermissions";
import { useWorkspace } from "~/providers/workspace";
interface SettingsLayoutProps {
children: React.ReactNode;
@@ -24,8 +27,12 @@ interface SettingsLayoutProps {
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
const router = useRouter();
const { workspace } = useWorkspace();
const { canViewWorkspace, canEditWorkspace } = usePermissions();
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const isAdmin = workspace.role === "admin";
const settingsTabs = [
{
key: "account",
@@ -37,13 +44,19 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
key: "workspace",
icon: <HiOutlineRectangleGroup />,
label: t`Workspace`,
condition: true,
condition: canViewWorkspace,
},
{
key: "permissions",
icon: <HiOutlineShieldCheck />,
label: t`Permissions`,
condition: isAdmin,
},
{
key: "billing",
label: t`Billing`,
icon: <HiOutlineBanknotes />,
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
},
{
key: "api",
@@ -55,7 +68,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
key: "integrations",
icon: <HiOutlineCodeBracketSquare />,
label: t`Integrations`,
condition: true,
condition: canEditWorkspace,
},
];
@@ -97,7 +110,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
>
<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">
{availableTabs[selectedTabIndex]?.label || "Select a tab"}
{availableTabs[selectedTabIndex]?.label ?? "Select a tab"}
<HiChevronDown
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"

View File

@@ -6,16 +6,20 @@ const Toggle = ({
onChange,
label,
disabled,
showLabel = true,
}: {
isChecked: boolean;
onChange: () => void;
label: string;
disabled?: boolean;
showLabel?: boolean;
}) => (
<div className="mr-4 flex items-center justify-end">
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
{showLabel && (
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
)}
<Switch
checked={isChecked}
onChange={onChange}

View File

@@ -77,7 +77,7 @@ export default function UserMenu({
) : (
<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"
title={isCollapsed ? displayName ?? email : undefined}
title={isCollapsed ? (displayName || email) : undefined}
>
{avatarUrl ? (
<Image
@@ -104,7 +104,7 @@ export default function UserMenu({
isCollapsed && "md:hidden",
)}
>
{displayName ?? email}
{displayName || email}
</span>
</Menu.Button>
)}

View File

@@ -9,6 +9,7 @@ interface Props {
positionFromTop?: "sm" | "md" | "lg";
isVisible?: boolean;
closeOnClickOutside?: boolean;
centered?: boolean;
}
const Modal: React.FC<Props> = ({
@@ -17,6 +18,7 @@ const Modal: React.FC<Props> = ({
positionFromTop = "md",
isVisible,
closeOnClickOutside,
centered = false,
}) => {
const {
isOpen,
@@ -60,7 +62,7 @@ const Modal: React.FC<Props> = ({
</Transition.Child>
<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
as={Fragment}
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"
>
<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}
</Dialog.Panel>

View File

@@ -78,7 +78,7 @@ export const env = createEnv({
S3_ENDPOINT: z.string().optional(),
S3_FORCE_PATH_STYLE: z.string().optional(),
EMAIL_FROM: z.string().optional(),
REDIS_URL: z.string().url().optional(),
REDIS_URL: z.string().url().optional().or(z.literal("")),
},
/**
@@ -96,6 +96,13 @@ export const env = createEnv({
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
.string()
@@ -134,6 +141,8 @@ export const env = createEnv({
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS:
process.env.NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS,
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,

View File

@@ -0,0 +1,109 @@
import type { Permission } from "@kan/shared";
import { useContext } from "react";
import { WorkspaceContext } from "~/providers/workspace";
import { api } from "~/utils/api";
interface UsePermissionsResult {
permissions: Permission[];
role: string | null;
isLoading: boolean;
hasPermission: (permission: Permission) => boolean;
canViewCard: boolean;
canCreateCard: boolean;
canEditCard: boolean;
canDeleteCard: boolean;
canCreateList: boolean;
canEditList: boolean;
canDeleteList: boolean;
canCreateBoard: boolean;
canEditBoard: boolean;
canDeleteBoard: boolean;
canViewComment: boolean;
canCreateComment: boolean;
canEditComment: boolean;
canDeleteComment: boolean;
canInviteMember: boolean;
canEditMember: boolean;
canRemoveMember: boolean;
canViewWorkspace: boolean;
canEditWorkspace: boolean;
}
export function usePermissions(): UsePermissionsResult {
// Check if WorkspaceProvider is available (for public board views, it may not be)
const workspaceContext = useContext(WorkspaceContext);
// If WorkspaceProvider is not available, return safe defaults
if (!workspaceContext) {
const emptyPermissions: UsePermissionsResult = {
permissions: [],
role: null,
isLoading: false,
hasPermission: () => false,
canViewCard: false,
canCreateCard: false,
canEditCard: false,
canDeleteCard: false,
canCreateList: false,
canEditList: false,
canDeleteList: false,
canCreateBoard: false,
canEditBoard: false,
canDeleteBoard: false,
canViewComment: false,
canCreateComment: false,
canEditComment: false,
canDeleteComment: false,
canInviteMember: false,
canEditMember: false,
canRemoveMember: false,
canViewWorkspace: false,
canEditWorkspace: false,
};
return emptyPermissions;
}
const { workspace } = workspaceContext;
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
{ workspacePublicId: workspace.publicId },
{
enabled: !!workspace.publicId,
},
);
const permissions = (data?.permissions ?? []) as Permission[];
const role = data?.role ?? null;
const hasPermission = (permission: Permission): boolean => {
return permissions.includes(permission);
};
return {
permissions,
role,
isLoading,
hasPermission,
canViewCard: hasPermission("card:view"),
canCreateCard: hasPermission("card:create"),
canEditCard: hasPermission("card:edit"),
canDeleteCard: hasPermission("card:delete"),
canCreateList: hasPermission("list:create"),
canEditList: hasPermission("list:edit"),
canDeleteList: hasPermission("list:delete"),
canCreateBoard: hasPermission("board:create"),
canEditBoard: hasPermission("board:edit"),
canDeleteBoard: hasPermission("board:delete"),
canViewComment: hasPermission("comment:view"),
canCreateComment: hasPermission("comment:create"),
canEditComment: hasPermission("comment:edit"),
canDeleteComment: hasPermission("comment:delete"),
canInviteMember: hasPermission("member:invite"),
canEditMember: hasPermission("member:edit"),
canRemoveMember: hasPermission("member:remove"),
canViewWorkspace: hasPermission("workspace:view"),
canEditWorkspace: hasPermission("workspace:edit"),
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import ApiSettings from "~/views/settings/ApiSettings";
import Popup from "~/components/Popup";
const ApiSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="api">
<ApiSettings />
<Popup />
</SettingsLayout>
);
};

View File

@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import BillingSettings from "~/views/settings/BillingSettings";
import Popup from "~/components/Popup";
const BillingSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="billing">
<BillingSettings />
<Popup />
</SettingsLayout>
);
};

View File

@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
import Popup from "~/components/Popup";
const IntegrationsSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="integrations">
<IntegrationsSettings />
<Popup />
</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 { SettingsLayout } from "~/components/SettingsLayout";
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
import Popup from "~/components/Popup";
const WorkspaceSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="workspace">
<WorkspaceSettings />
<Popup />
</SettingsLayout>
);
};

View File

@@ -32,7 +32,7 @@ const initialWorkspace: Workspace = {
const initialAvailableWorkspaces: Workspace[] = [];
const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
undefined,
);

View File

@@ -51,9 +51,10 @@ describe("getAvatarUrl", () => {
});
describe("virtual-hosted URLs (Tigris/AWS S3)", () => {
it("constructs virtual-hosted URL when STORAGE_DOMAIN is set", () => {
it("constructs virtual-hosted URL when USE_VIRTUAL_HOSTED_URLS is true and STORAGE_DOMAIN is set", () => {
mockEnv.mockImplementation((key: string) => {
const vars: Record<string, string> = {
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: "true",
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
@@ -65,5 +66,36 @@ describe("getAvatarUrl", () => {
"https://kan-avatars.fly.storage.tigris.dev/user123/avatar.jpg",
);
});
it("uses path-style URL when USE_VIRTUAL_HOSTED_URLS is false even if STORAGE_DOMAIN is set", () => {
mockEnv.mockImplementation((key: string) => {
const vars: Record<string, string> = {
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: "false",
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
};
return vars[key];
});
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
"https://fly.storage.tigris.dev/kan-avatars/user123/avatar.jpg",
);
});
it("uses path-style URL when USE_VIRTUAL_HOSTED_URLS is not set even if STORAGE_DOMAIN is set", () => {
mockEnv.mockImplementation((key: string) => {
const vars: Record<string, string> = {
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
};
return vars[key];
});
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
"https://fly.storage.tigris.dev/kan-avatars/user123/avatar.jpg",
);
});
});
});

View File

@@ -53,9 +53,10 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
}
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS");
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
if (storageDomain) {
if (useVirtualHostedUrls === "true" && storageDomain) {
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
}

View File

@@ -4,9 +4,12 @@ import {
HiLink,
HiOutlineDocumentDuplicate,
HiOutlineTrash,
HiOutlineStar,
HiStar,
} from "react-icons/hi2";
import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -15,42 +18,104 @@ export default function BoardDropdown({
isTemplate,
isLoading,
boardPublicId,
workspacePublicId,
isFavorite,
boardName,
}: {
isTemplate: boolean;
isLoading: boolean;
boardPublicId: string;
workspacePublicId: string;
isFavorite?: boolean;
boardName?: string;
}) {
const { openModal } = useModal();
return (
<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 { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
const { showPopup } = usePopup();
const utils = api.useUtils();
{
label: isTemplate ? t`Delete template` : t`Delete board`,
action: () => openModal("DELETE_BOARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
},
]}
>
const handleToggleFavorite = () => {
updateBoard.mutate({
boardPublicId,
favorite: !isFavorite,
});
};
const updateBoard = api.board.update.useMutation({
onSuccess: (data, variables) => {
void utils.board.all.invalidate();
void utils.board.byId.invalidate();
// Show popup notification
if (variables.favorite !== undefined) {
showPopup({
header: variables.favorite
? t`Added to favorites`
: t`Removed from favorites`,
message: variables.favorite
? t`${boardName ?? "Board"} has been added to your favorites.`
: t`${boardName ?? "Board"} has been removed from your favorites.`,
icon: "success",
});
}
},
onError: () => {
showPopup({
header: t`Unable to update board`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const items = [
...(isTemplate && canCreateBoard
? [
{
label: t`Make template`,
action: () => openModal("CREATE_TEMPLATE"),
icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
),
},
]
: []),
...(!isTemplate && canEditBoard
? [
{
label: t`Edit board URL`,
action: () => openModal("UPDATE_BOARD_SLUG"),
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
]
: []),
{
label: isFavorite
? t`Remove from favorites`
: t`Add to favorites`,
action: handleToggleFavorite,
icon: isFavorite ? (
<HiStar className="h-[16px] w-[16px] text-dark-900" />
) : (
<HiOutlineStar className="h-[16px] w-[16px] text-dark-900" />
),
},
...(canDeleteBoard
? [
{
label: isTemplate ? t`Delete template` : t`Delete board`,
action: () => openModal("DELETE_BOARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
},
]
: []),
];
if (items.length === 0) {
return null;
}
return (
<Dropdown disabled={isLoading} items={items}>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
);

View File

@@ -9,7 +9,11 @@ import {
HiOutlineTrash,
} from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
import Dropdown from "~/components/Dropdown";
import { Tooltip } from "~/components/Tooltip";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
@@ -23,6 +27,7 @@ interface ListProps {
interface List {
publicId: string;
name: string;
createdBy?: string | null;
}
interface FormValues {
@@ -39,8 +44,14 @@ export default function List({
setSelectedPublicListId,
}: ListProps) {
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) => {
if (!canCreateCard) return;
openModal("NEW_CARD");
setSelectedPublicListId(publicListId);
};
@@ -59,6 +70,7 @@ export default function List({
});
const onSubmit = (values: FormValues) => {
if (!canEdit) return;
updateList.mutate({
listPublicId: values.listPublicId,
name: values.name,
@@ -71,7 +83,12 @@ export default function List({
};
return (
<Draggable key={list.publicId} draggableId={list.publicId} index={index}>
<Draggable
key={list.publicId}
draggableId={list.publicId}
index={index}
isDragDisabled={!canDrag}
>
{(provided) => (
<div
key={list.publicId}
@@ -90,41 +107,65 @@ export default function List({
type="text"
{...register("name")}
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"
/>
</form>
<div className="flex items-center">
<button
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"
onClick={() => openNewCardForm(list.publicId)}
<Tooltip
content={
!canCreateCard ? t`You don't have permission` : undefined
}
>
<HiOutlinePlusSmall
className="h-5 w-5 text-dark-900"
aria-hidden="true"
/>
</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" />
),
},
]}
<button
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"
onClick={() => openNewCardForm(list.publicId)}
disabled={!canCreateCard}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
</div>
<HiOutlinePlusSmall
className="h-5 w-5 text-dark-900"
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>
{children}

View File

@@ -1,17 +1,22 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { HiLink } from "react-icons/hi";
import { Tooltip } from "~/components/Tooltip";
const UpdateBoardSlugButton = ({
handleOnClick,
workspaceSlug,
boardSlug,
isLoading,
canEdit,
}: {
handleOnClick: () => void;
workspaceSlug: string;
boardSlug: string;
isLoading: boolean;
canEdit: boolean;
}) => {
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
@@ -22,10 +27,14 @@ const UpdateBoardSlugButton = ({
}
return (
<button
onClick={handleOnClick}
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"
<Tooltip
content={!canEdit && !isLoading ? t`You don't have permission` : undefined}
>
<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">
<span>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
@@ -41,13 +50,20 @@ const UpdateBoardSlugButton = ({
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
target="_blank"
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"
>
<HiLink className="h-[13px] w-[13px]" />
</Link>
</button>
</Tooltip>
);
};
export default UpdateBoardSlugButton;

View File

@@ -4,6 +4,8 @@ import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import { Tooltip } from "~/components/Tooltip";
import { usePermissions } from "~/hooks/usePermissions";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -29,6 +31,7 @@ const VisibilityButton = ({
isAdmin: boolean;
}) => {
const { showPopup } = usePopup();
const { canEditBoard } = usePermissions();
const utils = api.useUtils();
const [stateVisibility, setStateVisibility] = useState<"public" | "private">(
visibility,
@@ -60,38 +63,47 @@ const VisibilityButton = ({
},
});
const canEdit = canEditBoard || isAdmin;
return (
<div className="relative">
<CheckboxDropdown
items={[
{
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"
<Tooltip
content={
!canEdit && !isLoading ? t`You don't have permission` : undefined
}
>
<Button
variant="secondary"
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
disabled={isLoading || !isAdmin}
<CheckboxDropdown
items={[
{
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>
</CheckboxDropdown>
<Button
variant="secondary"
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
disabled={isLoading || !canEdit}
>
{t`Visibility`}
</Button>
</CheckboxDropdown>
</Tooltip>
</div>
);
};

View File

@@ -26,6 +26,7 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
import { Tooltip } from "~/components/Tooltip";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { useDragToScroll } from "~/hooks/useDragToScroll";
import { usePermissions } from "~/hooks/usePermissions";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -63,11 +64,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
direction: "horizontal",
});
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions();
const { tooltipContent: createListShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
stroke: { key: "C" },
action: () => boardId && openNewListForm(boardId),
action: () => boardId && canCreateList && openNewListForm(boardId),
description: t`Create new list`,
group: "ACTIONS",
});
@@ -260,14 +263,14 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
return;
}
if (type === "LIST") {
if (type === "LIST" && canEditList) {
updateListMutation.mutate({
listPublicId: draggableId,
index: destination.index,
});
}
if (type === "CARD") {
if (type === "CARD" && canEditCard) {
updateCardMutation.mutate({
cardPublicId: draggableId,
@@ -412,10 +415,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
id="name"
type="text"
{...register("name")}
onBlur={handleSubmit(onSubmit)}
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]"
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
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>
)}
{!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">
@@ -438,6 +443,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isLoading={isLoading}
workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""}
canEdit={canEditBoard}
/>
<VisibilityButton
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
iconLeft={
<HiOutlinePlusSmall
@@ -469,9 +481,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
/>
}
onClick={() => {
if (boardId) openNewListForm(boardId);
if (boardId && canCreateList) openNewListForm(boardId);
}}
disabled={!boardData}
disabled={!boardData || !canCreateList}
>
{t`New list`}
</Button>
@@ -481,6 +493,8 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isLoading={!boardData}
boardPublicId={boardId ?? ""}
workspacePublicId={workspace.publicId}
isFavorite={boardData?.favorite}
boardName={boardData?.name}
/>
</div>
</div>
@@ -506,16 +520,25 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
{t`No lists`}
</p>
<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>
</div>
<Button
onClick={() => {
if (boardId) openNewListForm(boardId);
}}
<Tooltip
content={
!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>
) : (
<DragDropContext onDragEnd={onDragEnd}>
@@ -555,6 +578,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
key={card.publicId}
draggableId={card.publicId}
index={index}
isDragDisabled={!canEditCard}
>
{(provided) => (
<Link
@@ -572,13 +596,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
? `/templates/${boardId}/cards/${card.publicId}`
: `/cards/${card.publicId}`
}
className={`mb-2 flex !cursor-pointer flex-col ${
card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}

View File

@@ -1,9 +1,11 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { HiOutlineRectangleStack } from "react-icons/hi2";
import { HiOutlineRectangleStack, HiOutlineStar, HiStar } from "react-icons/hi2";
import { motion } from "framer-motion";
import Button from "~/components/Button";
import PatternedBackground from "~/components/PatternedBackground";
import { Tooltip } from "~/components/Tooltip";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
@@ -11,6 +13,14 @@ import { api } from "~/utils/api";
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
const { workspace } = useWorkspace();
const { openModal } = useModal();
const { canCreateBoard } = usePermissions();
const utils = api.useUtils();
const updateBoard = api.board.update.useMutation({
onSuccess: () => {
void utils.board.all.invalidate();
},
});
const { data, isLoading } = api.board.all.useQuery(
{
@@ -20,6 +30,20 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
{ enabled: workspace.publicId ? true : false },
);
const handleToggleFavorite = (
e: React.MouseEvent,
boardPublicId: string,
currentFavorite: boolean | undefined
) => {
e.preventDefault();
e.stopPropagation();
updateBoard.mutate({
boardPublicId,
favorite: !currentFavorite,
});
};
if (isLoading)
return (
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
@@ -41,27 +65,69 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
</p>
</div>
<Button onClick={() => openModal("NEW_BOARD")}>
{t`Create new ${isTemplate ? "template" : "board"}`}
</Button>
<Tooltip
content={
!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>
);
return (
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
<motion.div
className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3"
layout
>
{data?.map((board) => (
<Link
<motion.div
key={board.publicId}
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
layout: {
type: "spring",
stiffness: 300,
damping: 30,
mass: 1
},
opacity: { duration: 0.2 },
scale: { duration: 0.2 }
}}
>
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
<PatternedBackground />
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
{board.name}
</p>
</div>
</Link>
<Link
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
>
<div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
<PatternedBackground />
<button
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
}`}
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
>
{board.favorite ? (
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
) : (
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
)}
</button>
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
{board.name}
</p>
</div>
</Link>
</motion.div>
))}
</div>
</motion.div>
);
}

View File

@@ -7,6 +7,7 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { Tooltip } from "~/components/Tooltip";
import { usePermissions } from "~/hooks/usePermissions";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
@@ -17,12 +18,13 @@ import { NewBoardForm } from "./components/NewBoardForm";
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
const { openModal, modalContentType, isOpen } = useModal();
const { workspace } = useWorkspace();
const { canCreateBoard } = usePermissions();
const { tooltipContent: createModalShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
stroke: { key: "C" },
action: () => openModal("NEW_BOARD"),
action: () => canCreateBoard && openModal("NEW_BOARD"),
description: t`Create new ${isTemplate ? "template" : "board"}`,
group: "ACTIONS",
});
@@ -39,22 +41,40 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
</h1>
<div className="flex gap-2">
{!isTemplate && (
<Button
type="button"
variant="secondary"
onClick={() => openModal("IMPORT_BOARDS")}
iconLeft={
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
<Tooltip
content={
!canCreateBoard ? t`You don't have permission` : undefined
}
>
{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
type="button"
variant="primary"
onClick={() => openModal("NEW_BOARD")}
onClick={() => {
if (canCreateBoard) openModal("NEW_BOARD");
}}
disabled={!canCreateBoard}
iconLeft={
<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 Button from "~/components/Button";
import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -49,6 +50,7 @@ const Comment = ({
const utils = api.useUtils();
const { showPopup } = usePopup();
const { openModal } = useModal();
const { canEditComment, canDeleteComment } = usePermissions();
const { handleSubmit, setValue, watch } = useForm<FormValues>({
defaultValues: {
comment,
@@ -80,7 +82,7 @@ const Comment = ({
};
const dropdownItems = [
...(isAuthor
...(isAuthor && canEditComment
? [
{
label: t`Edit comment`,
@@ -89,7 +91,7 @@ const Comment = ({
},
]
: []),
...(isAuthor || isAdmin
...((isAuthor || canDeleteComment)
? [
{
label: t`Delete comment`,

View File

@@ -5,29 +5,53 @@ import {
HiOutlineTrash,
} from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
export default function BoardDropdown() {
export default function CardDropdown({
cardCreatedBy,
}: {
cardCreatedBy?: string | null;
}) {
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 (
<Dropdown
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" />,
},
]}
>
<Dropdown items={items}>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
);

View File

@@ -12,12 +12,14 @@ interface DueDateSelectorProps {
cardPublicId: string;
dueDate: Date | null | undefined;
isLoading?: boolean;
disabled?: boolean;
}
export function DueDateSelector({
cardPublicId,
dueDate,
isLoading = false,
disabled = false,
}: DueDateSelectorProps) {
const { showPopup } = usePopup();
const utils = api.useUtils();
@@ -105,9 +107,9 @@ export function DueDateSelector({
<div className="relative flex w-full items-center text-left">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
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"
onClick={() => !disabled && setIsOpen(!isOpen)}
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 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 ? (
<span>{format(dueDate, "MMM d, yyyy")}</span>
@@ -118,7 +120,7 @@ export function DueDateSelector({
</>
)}
</button>
{isOpen && (
{isOpen && !disabled && (
<>
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
<div

View File

@@ -17,12 +17,14 @@ interface LabelSelectorProps {
leftIcon: React.ReactNode;
}[];
isLoading: boolean;
disabled?: boolean;
}
export default function LabelSelector({
cardPublicId,
labels,
isLoading,
disabled = false,
}: LabelSelectorProps) {
const utils = api.useUtils();
const { openModal } = useModal();
@@ -93,9 +95,10 @@ export default function LabelSelector({
handleSelect={(_, label) => {
addOrRemoveLabel.mutate({ cardPublicId, labelPublicId: label.key });
}}
handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
handleCreate={() => openModal("NEW_LABEL")}
handleEdit={disabled ? undefined : (labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
handleCreate={disabled ? undefined : () => openModal("NEW_LABEL")}
createNewItemLabel={t`Create new label`}
disabled={disabled}
asChild
>
{selectedLabels.length ? (
@@ -110,7 +113,7 @@ export default function LabelSelector({
<Badge value={t`Add label`} iconLeft={<HiMiniPlus size={14} />} />
</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" />
{t`Add label`}
</div>

View File

@@ -13,12 +13,14 @@ interface ListSelectorProps {
selected: boolean;
}[];
isLoading: boolean;
disabled?: boolean;
}
export default function ListSelector({
cardPublicId,
lists,
isLoading,
disabled = false,
}: ListSelectorProps) {
const utils = api.useUtils();
@@ -77,9 +79,10 @@ export default function ListSelector({
index: 0,
});
}}
disabled={disabled}
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}
</div>
</CheckboxDropdown>

View File

@@ -19,12 +19,14 @@ interface MemberSelectorProps {
imageUrl: string | undefined;
}[];
isLoading: boolean;
disabled?: boolean;
}
export default function MemberSelector({
cardPublicId,
members,
isLoading,
disabled = false,
}: MemberSelectorProps) {
const router = useRouter();
const utils = api.useUtils();
@@ -108,11 +110,12 @@ export default function MemberSelector({
workspaceMemberPublicId: member.key,
});
}}
handleCreate={handleInviteMember}
handleCreate={disabled ? undefined : handleInviteMember}
createNewItemLabel={t`Invite member`}
disabled={disabled}
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 ? (
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
{selectedMembers.map(({ value, imageUrl }) => (

View File

@@ -4,6 +4,7 @@ import { useForm } from "react-hook-form";
import { HiOutlineArrowUp } from "react-icons/hi2";
import LoadingSpinner from "~/components/LoadingSpinner";
import { usePermissions } from "~/hooks/usePermissions";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
@@ -15,6 +16,7 @@ interface FormValues {
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
const { canCreateComment } = usePermissions();
const { handleSubmit, setValue, watch, reset } = useForm<FormValues>({
values: {
comment: "",
@@ -46,6 +48,10 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
});
};
if (!canCreateComment) {
return null;
}
return (
<form
onSubmit={handleSubmit(onSubmit)}

View File

@@ -14,6 +14,9 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { authClient } from "@kan/auth/client";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
@@ -44,6 +47,8 @@ interface FormValues {
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
const router = useRouter();
const { canEditCard } = usePermissions();
const { data: session } = authClient.useSession();
const cardId = Array.isArray(router.query.cardId)
? router.query.cardId[0]
: router.query.cardId;
@@ -52,6 +57,9 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId: cardId ?? "",
});
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
const canEdit = canEditCard || isCreator;
const board = card?.list.board;
const labels = board?.labels;
const workspaceMembers = board?.workspace.members;
@@ -116,6 +124,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId={cardId ?? ""}
lists={formattedLists}
isLoading={!card}
disabled={!canEdit}
/>
</div>
<div className="mb-4 flex w-full flex-row">
@@ -124,6 +133,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId={cardId ?? ""}
labels={formattedLabels}
isLoading={!card}
disabled={!canEdit}
/>
</div>
{!isTemplate && (
@@ -133,6 +143,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId={cardId ?? ""}
members={formattedMembers}
isLoading={!card}
disabled={!canEdit}
/>
</div>
)}
@@ -142,6 +153,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId={cardId ?? ""}
dueDate={card?.dueDate}
isLoading={!card}
disabled={!canEdit}
/>
</div>
</div>
@@ -162,6 +174,8 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
} = useModal();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const { canEditCard } = usePermissions();
const { data: session } = authClient.useSession();
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
null,
);
@@ -174,6 +188,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId: cardId ?? "",
});
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
const canEdit = canEditCard || isCreator;
const refetchCard = async () => {
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
};
@@ -298,7 +315,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
</Link>
</div>
<div className="flex items-center gap-2">
<Dropdown />
<Dropdown cardCreatedBy={card?.createdBy} />
</div>
</>
)}
@@ -326,9 +343,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<textarea
id="title"
{...register("title")}
onBlur={handleSubmit(onSubmit)}
onBlur={canEdit ? handleSubmit(onSubmit) : undefined}
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) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
@@ -354,9 +372,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<div className="mt-2">
<Editor
content={card.description}
onChange={(e) => setValue("description", e)}
onBlur={() => handleSubmit(onSubmit)()}
onChange={canEdit ? (e) => setValue("description", e) : undefined}
onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined}
workspaceMembers={board?.workspace.members ?? []}
readOnly={!canEdit}
/>
</div>
</form>
@@ -366,6 +385,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId={cardId}
activeChecklistForm={activeChecklistForm}
setActiveChecklistForm={setActiveChecklistForm}
viewOnly={!canEdit}
/>
{!isTemplate && (
<>
@@ -374,12 +394,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<AttachmentThumbnails
attachments={card.attachments}
cardPublicId={cardId ?? ""}
isReadOnly={!canEdit}
/>
</div>
)}
<div className="mt-6">
<AttachmentUpload cardPublicId={cardId} />
</div>
{canEdit && (
<div className="mt-6">
<AttachmentUpload cardPublicId={cardId} />
</div>
)}
</>
)}
<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 {
HiBolt,
HiChevronDown,
HiEllipsisHorizontal,
HiOutlinePlusSmall,
} from "react-icons/hi2";
@@ -19,16 +20,20 @@ import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { getAvatarUrl } from "~/utils/helpers";
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
import { InviteMemberForm } from "./components/InviteMemberForm";
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
export default function MembersPage() {
const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace();
const { showPopup } = usePopup();
const { data, isLoading } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
@@ -37,6 +42,31 @@ export default function MembersPage() {
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 teamSubscription = getSubscriptionByPlan(subscriptions, "team");
@@ -67,6 +97,16 @@ export default function MembersPage() {
showSkeleton?: boolean;
showPendingIcon?: boolean;
}) => {
const handleRoleChange = (newRole: "admin" | "member" | "guest") => {
if (!memberPublicId) return;
updateRoleMutation.mutate({
workspacePublicId: workspace.publicId,
memberPublicId,
role: newRole,
});
};
return (
<tr className="rounded-b-lg">
<td
@@ -127,32 +167,67 @@ export default function MembersPage() {
)}
>
<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">
<span
className={twMerge(
"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]",
showSkeleton &&
<div className="flex items-center gap-2">
{showSkeleton ? (
<span
className={twMerge(
"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",
)}
>
{memberRole &&
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
</span>
)}
/>
) : (
<div className="relative inline-flex items-center">
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]">
{memberRole &&
memberRole.charAt(0).toUpperCase() +
memberRole.slice(1)}
{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") && (
<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`}
</span>
)}
</div>
<div
className={twMerge(
"relative z-50",
"relative",
(workspace.role !== "admin" || showSkeleton) && "hidden",
)}
>
{session?.user.id !== memberId && (
<Dropdown
items={[
{
label: t`Edit permissions`,
action: () =>
openModal(
"EDIT_MEMBER_PERMISSIONS",
memberPublicId,
memberEmail ?? "",
),
},
{
label: t`Remove member`,
action: () =>
@@ -166,7 +241,7 @@ export default function MembersPage() {
>
<HiEllipsisHorizontal
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>
)}
@@ -321,6 +396,14 @@ export default function MembersPage() {
>
<DeleteMemberConfirmation />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_MEMBER_PERMISSIONS"}
centered
>
<EditMemberPermissionsModal />
</Modal>
</>
</div>
</>

View File

@@ -1,7 +1,6 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import { api } from "~/utils/api";

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 { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
@@ -25,6 +26,7 @@ import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation"
export default function WorkspaceSettings() {
const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace();
const { canEditWorkspace } = usePermissions();
const router = useRouter();
const { data } = api.user.getUser.useQuery();
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
@@ -61,6 +63,7 @@ export default function WorkspaceSettings() {
<UpdateWorkspaceNameForm
workspacePublicId={workspace.publicId}
workspaceName={workspace.name}
disabled={!canEditWorkspace}
/>
<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}
workspaceUrl={workspace.slug ?? ""}
workspacePlan={workspace.plan ?? "free"}
disabled={!canEditWorkspace}
/>
<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
workspacePublicId={workspace.publicId}
workspaceDescription={workspace.description ?? ""}
disabled={!canEditWorkspace}
/>
<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>
<UpdateWorkspaceEmailVisibilityForm
workspacePublicId={workspace.publicId}
showEmailsToMembers={workspaceData?.showEmailsToMembers ?? false}
showEmailsToMembers={Boolean(
workspaceData?.showEmailsToMembers ?? false,
)}
disabled={!canEditWorkspace}
/>
{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 = ({
workspacePublicId,
workspaceDescription,
disabled = false,
}: {
workspacePublicId: string;
workspaceDescription: string;
disabled?: boolean;
}) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
@@ -78,9 +80,10 @@ const UpdateWorkspaceDescriptionForm = ({
<Input
{...register("description")}
errorMessage={errors.description?.message}
disabled={disabled}
/>
</div>
{isDirty && (
{isDirty && !disabled && (
<div>
<Button
onClick={handleSubmit(onSubmit)}

View File

@@ -7,9 +7,11 @@ import { api } from "~/utils/api";
export default function UpdateWorkspaceEmailVisibilityForm({
workspacePublicId,
showEmailsToMembers,
disabled = false,
}: {
workspacePublicId: string;
showEmailsToMembers: boolean;
disabled?: boolean;
}) {
const utils = api.useUtils();
const [isChecked, setIsChecked] = useState(showEmailsToMembers);
@@ -27,6 +29,7 @@ export default function UpdateWorkspaceEmailVisibilityForm({
});
const handleToggle = () => {
if (disabled) return;
const newValue = !isChecked;
setIsChecked(newValue);
updateWorkspace.mutate({
@@ -46,7 +49,7 @@ export default function UpdateWorkspaceEmailVisibilityForm({
isChecked={isChecked}
onChange={handleToggle}
label=""
disabled={updateWorkspace.isPending}
disabled={disabled || updateWorkspace.isPending}
/>
</div>
);

View File

@@ -20,9 +20,11 @@ type FormValues = z.infer<typeof schema>;
const UpdateWorkspaceNameForm = ({
workspacePublicId,
workspaceName,
disabled = false,
}: {
workspacePublicId: string;
workspaceName: string;
disabled?: boolean;
}) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
@@ -70,9 +72,13 @@ const UpdateWorkspaceNameForm = ({
return (
<div className="flex 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>
{isDirty && (
{isDirty && !disabled && (
<div>
<Button
onClick={handleSubmit(onSubmit)}

View File

@@ -16,10 +16,12 @@ const UpdateWorkspaceUrlForm = ({
workspacePublicId,
workspaceUrl,
workspacePlan,
disabled = false,
}: {
workspacePublicId: string;
workspaceUrl: string;
workspacePlan: "free" | "pro" | "enterprise";
disabled?: boolean;
}) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
@@ -136,9 +138,10 @@ const UpdateWorkspaceUrlForm = ({
<HiCheck className="h-4 w-4 dark:text-dark-1000" />
) : null
}
disabled={disabled}
/>
</div>
{isDirty && (
{isDirty && !disabled && (
<div>
<Button
onClick={handleSubmit(onSubmit)}

View File

@@ -56,6 +56,7 @@ services:
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
- NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=${NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS}
# Auth config (optional)
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}

View File

@@ -45,6 +45,7 @@ services:
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
- NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=${NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS}
# White label
- NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY=${NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY}

View File

@@ -9,6 +9,7 @@ import { integrationRouter } from "./routers/integration";
import { labelRouter } from "./routers/label";
import { listRouter } from "./routers/list";
import { memberRouter } from "./routers/member";
import { permissionRouter } from "./routers/permission";
import { userRouter } from "./routers/user";
import { workspaceRouter } from "./routers/workspace";
import { createTRPCRouter } from "./trpc";
@@ -24,6 +25,7 @@ export const appRouter = createTRPCRouter({
list: listRouter,
member: memberRouter,
import: importRouter,
permission: permissionRouter,
user: userRouter,
workspace: workspaceRouter,
integration: integrationRouter,

View File

@@ -8,7 +8,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertPermission } from "../utils/permissions";
import { deleteObject, generateUploadUrl } from "../utils/s3";
export const attachmentRouter = createTRPCRouter({
@@ -55,8 +55,7 @@ export const attachmentRouter = createTRPCRouter({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
// Get workspace publicId
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`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
const attachment = await cardAttachmentRepo.create(ctx.db, {
cardId: card.id,
@@ -186,8 +184,7 @@ export const attachmentRouter = createTRPCRouter({
});
const workspaceId = attachment.card.list.board.workspaceId;
await assertUserInWorkspace(ctx.db, userId, workspaceId);
await assertPermission(ctx.db, userId, workspaceId, "card:edit");
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
if (bucket) {

View File

@@ -15,7 +15,7 @@ import {
} from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
export const boardRouter = createTRPCRouter({
all: protectedProcedure
@@ -58,11 +58,14 @@ export const boardRouter = createTRPCRouter({
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, {
type: input.type,
});
const result = boardRepo.getAllByWorkspaceId(
ctx.db,
workspace.id,
userId,
{ type: input.type }
);
return result;
}),
@@ -119,7 +122,7 @@ export const boardRouter = createTRPCRouter({
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
const dueDateFilters = input.dueDateFilters
@@ -129,6 +132,7 @@ export const boardRouter = createTRPCRouter({
const result = await boardRepo.getByPublicId(
ctx.db,
input.boardPublicId,
userId,
{
members: input.members ?? [],
labels: input.labels ?? [],
@@ -255,7 +259,7 @@ export const boardRouter = createTRPCRouter({
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 (input.sourceBoardPublicId) {
@@ -275,6 +279,7 @@ export const boardRouter = createTRPCRouter({
const sourceBoard = await boardRepo.getByPublicId(
ctx.db,
input.sourceBoardPublicId,
userId,
{
members: [],
labels: [],
@@ -399,9 +404,10 @@ export const boardRouter = createTRPCRouter({
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(),
visibility: z.enum(["public", "private"]).optional(),
favorite: z.boolean().optional()
}),
)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>())
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -422,7 +428,30 @@ export const boardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
await assertCanEdit(
ctx.db,
userId,
board.workspaceId,
"board:edit",
board.createdBy ?? null,
);
// Handle favorite toggle separately
if (input.favorite !== undefined) {
if (input.favorite) {
await boardRepo.addUserFavorite(ctx.db, userId, board.id);
} else {
await boardRepo.removeUserFavorite(ctx.db, userId, board.id);
}
}
// Handle other updates (name, slug, visibility)
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined;
if (!hasOtherUpdates) {
// Only favorite was updated, return success
return { success: true };
}
if (input.slug) {
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
@@ -491,7 +520,13 @@ export const boardRouter = createTRPCRouter({
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);

View File

@@ -10,7 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { mergeActivities } from "../utils/activities";
import { assertUserInWorkspace } from "../utils/auth";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
import { generateDownloadUrl } from "../utils/s3";
export const cardRouter = createTRPCRouter({
@@ -57,13 +57,7 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, list.workspaceId);
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
await assertPermission(ctx.db, userId, list.workspaceId, "card:create");
const newCard = await cardRepo.create(ctx.db, {
title: input.title,
@@ -199,7 +193,7 @@ export const cardRouter = createTRPCRouter({
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, {
comment: input.comment,
@@ -262,8 +256,6 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
const existingComment = await cardCommentRepo.getByPublicId(
ctx.db,
input.commentPublicId,
@@ -275,11 +267,13 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
if (existingComment.createdBy !== userId)
throw new TRPCError({
message: `You do not have permission to update this comment`,
code: "FORBIDDEN",
});
await assertCanEdit(
ctx.db,
userId,
card.workspaceId,
"comment:edit",
existingComment.createdBy,
);
const updatedComment = await cardCommentRepo.update(ctx.db, {
id: existingComment.id,
@@ -340,8 +334,6 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
const existingComment = await cardCommentRepo.getByPublicId(
ctx.db,
input.commentPublicId,
@@ -353,6 +345,14 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertCanDelete(
ctx.db,
userId,
card.workspaceId,
"comment:delete",
existingComment.createdBy,
);
const deletedComment = await cardCommentRepo.softDelete(ctx.db, {
commentId: existingComment.id,
deletedAt: new Date(),
@@ -412,7 +412,7 @@ export const cardRouter = createTRPCRouter({
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);
@@ -504,7 +504,7 @@ export const cardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
const member = await workspaceRepo.getMemberByPublicId(
ctx.db,
@@ -616,7 +616,7 @@ export const cardRouter = createTRPCRouter({
code: "UNAUTHORIZED",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:view");
}
const result = await cardRepo.getWithListAndMembersByPublicId(
@@ -725,7 +725,7 @@ export const cardRouter = createTRPCRouter({
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;
@@ -788,7 +788,13 @@ export const cardRouter = createTRPCRouter({
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(
ctx.db,
@@ -958,7 +964,13 @@ export const cardRouter = createTRPCRouter({
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();

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 { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertPermission } from "../utils/permissions";
const checklistSchema = z.object({
publicId: z.string().length(12),
@@ -57,8 +57,7 @@ export const checklistRouter = createTRPCRouter({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
const newChecklist = await checklistRepo.create(ctx.db, {
name: input.name,
@@ -106,11 +105,11 @@ export const checklistRouter = createTRPCRouter({
message: `Checklist with public ID ${input.checklistPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
await assertPermission(
ctx.db,
userId,
checklist.card.list.board.workspace.id,
"card:edit",
);
const previousName = checklist.name;
@@ -166,11 +165,11 @@ export const checklistRouter = createTRPCRouter({
message: `Checklist with public ID ${input.checklistPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
await assertPermission(
ctx.db,
userId,
checklist.card.list.board.workspace.id,
"card:edit",
);
await checklistRepo.softDeleteAllItemsByChecklistId(ctx.db, {
@@ -237,11 +236,11 @@ export const checklistRouter = createTRPCRouter({
message: `Checklist with public ID ${input.checklistPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
await assertPermission(
ctx.db,
userId,
checklist.card.list.board.workspace.id,
"card:edit",
);
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`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
await assertPermission(
ctx.db,
userId,
item.checklist.card.list.board.workspace.id,
"card:edit",
);
const previousTitle = item.title;
@@ -394,11 +393,11 @@ export const checklistRouter = createTRPCRouter({
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
await assertPermission(
ctx.db,
userId,
item.checklist.card.list.board.workspace.id,
"card:edit",
);
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 { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertPermission } from "../utils/permissions";
import { apiKeys, urls } from "./integration";
export interface TrelloBoard {
@@ -180,8 +180,7 @@ export const importRouter = createTRPCRouter({
message: `Workspace with public ID ${input.workspacePublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
await assertPermission(ctx.db, userId, workspace.id, "board:create");
const newImport = await importRepo.create(ctx.db, {
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 { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertPermission } from "../utils/permissions";
const labelSchema = z.object({
publicId: z.string(),
@@ -47,8 +47,7 @@ export const labelRouter = createTRPCRouter({
message: `Label with public ID ${input.labelPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, label.workspaceId);
await assertPermission(ctx.db, userId, label.workspaceId, "board:view");
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`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
await assertPermission(ctx.db, userId, board.workspaceId, "board:edit");
const result = await labelRepo.create(ctx.db, {
name: input.name,
@@ -162,8 +160,7 @@ export const labelRouter = createTRPCRouter({
message: `Label with public ID ${input.labelPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, label.workspaceId);
await assertPermission(ctx.db, userId, label.workspaceId, "board:edit");
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`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, label.workspaceId);
await assertPermission(ctx.db, userId, label.workspaceId, "board:edit");
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 { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
export const listRouter = createTRPCRouter({
create: protectedProcedure
@@ -48,7 +48,7 @@ export const listRouter = createTRPCRouter({
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, {
name: input.name,
@@ -101,7 +101,13 @@ export const listRouter = createTRPCRouter({
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();
@@ -183,7 +189,13 @@ export const listRouter = createTRPCRouter({
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;

View File

@@ -4,6 +4,7 @@ import { z } from "zod";
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.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 userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
@@ -11,11 +12,15 @@ import {
generateUID,
getSubscriptionByPlan,
hasUnlimitedSeats,
} from "@kan/shared/utils";
} from "@kan/shared";
import { updateSubscriptionSeats } from "@kan/stripe";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import {
assertCanManageMember,
assertCanManageRole,
assertPermission,
} from "../utils/permissions";
export const memberRouter = createTRPCRouter({
invite: protectedProcedure
@@ -56,7 +61,7 @@ export const memberRouter = createTRPCRouter({
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(
(member) => member.email === input.email,
@@ -112,12 +117,20 @@ export const memberRouter = createTRPCRouter({
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, {
workspaceId: workspace.id,
email: input.email,
userId: existingUser?.id ?? null,
createdBy: userId,
role: "member",
roleId: memberRole?.id ?? null,
status: "invited",
});
@@ -190,7 +203,7 @@ export const memberRouter = createTRPCRouter({
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(
ctx.db,
@@ -292,8 +305,8 @@ export const memberRouter = createTRPCRouter({
code: "NOT_FOUND",
});
// Check if user is in workspace
await assertUserInWorkspace(ctx.db, userId, workspace.id);
// Check if user can view members
await assertPermission(ctx.db, userId, workspace.id, "member:view");
// Get active invite link for this workspace
const activeInviteLink = await inviteLinkRepo.getActiveForWorkspace(
@@ -360,8 +373,8 @@ export const memberRouter = createTRPCRouter({
code: "NOT_FOUND",
});
// Check if user is in workspace
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
// Check if user can edit members (admin-equivalent)
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
// Check subscription for cloud environment
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
@@ -461,8 +474,8 @@ export const memberRouter = createTRPCRouter({
code: "NOT_FOUND",
});
// Check if user is in workspace
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
// Check if user can edit members (admin-equivalent)
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
// Deactivate all active invite links
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, {
workspaceId: invite.workspaceId,
email: user.email,
userId: user.id,
createdBy: user.id,
role: "member",
roleId: memberRole?.id ?? null,
status: "active",
});
@@ -646,4 +667,85 @@ export const memberRouter = createTRPCRouter({
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 { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { assertPermission } from "../utils/permissions";
export const workspaceRouter = createTRPCRouter({
all: protectedProcedure
@@ -74,8 +74,7 @@ export const workspaceRouter = createTRPCRouter({
message: `Workspace not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, result.id);
await assertPermission(ctx.db, userId, result.id, "workspace:view");
// Check if user is an admin
const userMember = result.members.find(
@@ -84,7 +83,8 @@ export const workspaceRouter = createTRPCRouter({
const isAdmin = userMember?.role === "admin";
// 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 (!shouldShowEmails) {
@@ -164,8 +164,7 @@ export const workspaceRouter = createTRPCRouter({
message: `Workspace not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, result.id);
await assertPermission(ctx.db, userId, result.id, "workspace:view");
return result;
}),
@@ -296,8 +295,7 @@ export const workspaceRouter = createTRPCRouter({
message: `Workspace not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
await assertPermission(ctx.db, userId, workspace.id, "workspace:edit");
if (input.slug) {
const reservedOrPremiumWorkspaceSlug =
@@ -379,8 +377,7 @@ export const workspaceRouter = createTRPCRouter({
message: `Workspace not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
const result = await workspaceRepo.hardDelete(
ctx.db,
@@ -518,8 +515,7 @@ export const workspaceRouter = createTRPCRouter({
message: `Workspace not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
await assertPermission(ctx.db, userId, workspace.id, "workspace:view");
const result = await workspaceRepo.searchBoardsAndCards(
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

@@ -1 +1 @@
ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean NOT NULL DEFAULT true;
ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "showEmailsToMembers" boolean NOT NULL DEFAULT true;

View File

@@ -0,0 +1,171 @@
CREATE TABLE IF NOT EXISTS "workspace_member_permissions" (
"id" bigserial PRIMARY KEY NOT NULL,
"workspaceMemberId" bigint NOT NULL,
"permission" varchar(64) NOT NULL,
"granted" boolean DEFAULT true NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
ALTER TABLE "workspace_member_permissions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "workspace_role_permissions" (
"id" bigserial PRIMARY KEY NOT NULL,
"workspaceRoleId" bigint NOT NULL,
"permission" varchar(64) NOT NULL,
"granted" boolean DEFAULT true NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "workspace_role_permissions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "workspace_roles" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"workspaceId" bigint NOT NULL,
"name" varchar(64) NOT NULL,
"description" varchar(255),
"hierarchyLevel" integer NOT NULL,
"isSystem" boolean DEFAULT false NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
CONSTRAINT "workspace_roles_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
ALTER TABLE "workspace_roles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "workspace_members" ADD COLUMN "roleId" bigint;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_role_permissions" ADD CONSTRAINT "workspace_role_permissions_workspaceRoleId_workspace_roles_id_fk" FOREIGN KEY ("workspaceRoleId") REFERENCES "public"."workspace_roles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_roles" ADD CONSTRAINT "workspace_roles_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "unique_member_permission" ON "workspace_member_permissions" USING btree ("workspaceMemberId","permission");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "permission_member_idx" ON "workspace_member_permissions" USING btree ("workspaceMemberId");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "unique_role_permission" ON "workspace_role_permissions" USING btree ("workspaceRoleId","permission");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "role_permissions_role_idx" ON "workspace_role_permissions" USING btree ("workspaceRoleId");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "unique_role_per_workspace" ON "workspace_roles" USING btree ("workspaceId","name");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_roles_workspace_idx" ON "workspace_roles" USING btree ("workspaceId");--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_roleId_workspace_roles_id_fk" FOREIGN KEY ("roleId") REFERENCES "public"."workspace_roles"("id") ON DELETE restrict ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
-- Helper function to generate 12-character public IDs
CREATE OR REPLACE FUNCTION generate_public_id() RETURNS varchar(12) AS $$
DECLARE
chars text := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
result varchar(12) := '';
i integer;
BEGIN
FOR i IN 1..12 LOOP
result := result || substr(chars, floor(random() * length(chars) + 1)::integer, 1);
END LOOP;
RETURN result;
END;
$$ LANGUAGE plpgsql;
--> statement-breakpoint
-- Seed system roles for each existing workspace
INSERT INTO "workspace_roles" ("publicId", "workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt")
SELECT generate_public_id(), w.id, 'admin', 'Full access to all workspace features', 100, true, NOW()
FROM "workspace" w
WHERE NOT EXISTS (
SELECT 1 FROM "workspace_roles" wr
WHERE wr."workspaceId" = w.id AND wr."name" = 'admin'
);
--> statement-breakpoint
INSERT INTO "workspace_roles" ("publicId", "workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt")
SELECT generate_public_id(), w.id, 'member', 'Standard member with create and edit permissions', 50, true, NOW()
FROM "workspace" w
WHERE NOT EXISTS (
SELECT 1 FROM "workspace_roles" wr
WHERE wr."workspaceId" = w.id AND wr."name" = 'member'
);
--> statement-breakpoint
INSERT INTO "workspace_roles" ("publicId", "workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt")
SELECT generate_public_id(), w.id, 'guest', 'View-only access', 10, true, NOW()
FROM "workspace" w
WHERE NOT EXISTS (
SELECT 1 FROM "workspace_roles" wr
WHERE wr."workspaceId" = w.id AND wr."name" = 'guest'
);
--> statement-breakpoint
-- Seed admin role permissions (all permissions)
INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt")
SELECT wr.id, p.permission, true, NOW()
FROM "workspace_roles" wr
CROSS JOIN (
VALUES
('workspace:view'), ('workspace:edit'), ('workspace:delete'), ('workspace:manage'),
('board:view'), ('board:create'), ('board:edit'), ('board:delete'),
('list:view'), ('list:create'), ('list:edit'), ('list:delete'),
('card:view'), ('card:create'), ('card:edit'), ('card:delete'),
('comment:view'), ('comment:create'), ('comment:edit'), ('comment:delete'),
('member:view'), ('member:invite'), ('member:edit'), ('member:remove')
) AS p(permission)
WHERE wr."name" = 'admin' AND wr."isSystem" = true
AND NOT EXISTS (
SELECT 1 FROM "workspace_role_permissions" wrp
WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission
);
--> statement-breakpoint
-- Seed member role permissions
INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt")
SELECT wr.id, p.permission, true, NOW()
FROM "workspace_roles" wr
CROSS JOIN (
VALUES
('workspace:view'),
('board:view'), ('board:create'),
('list:view'), ('list:create'), ('list:edit'), ('list:delete'),
('card:view'), ('card:create'), ('card:edit'), ('card:delete'),
('comment:view'), ('comment:create'), ('comment:edit'), ('comment:delete'),
('member:view')
) AS p(permission)
WHERE wr."name" = 'member' AND wr."isSystem" = true
AND NOT EXISTS (
SELECT 1 FROM "workspace_role_permissions" wrp
WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission
);
--> statement-breakpoint
-- Seed guest role permissions (view only)
INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt")
SELECT wr.id, p.permission, true, NOW()
FROM "workspace_roles" wr
CROSS JOIN (
VALUES
('workspace:view'),
('board:view'),
('list:view'),
('card:view'),
('comment:view'),
('member:view')
) AS p(permission)
WHERE wr."name" = 'guest' AND wr."isSystem" = true
AND NOT EXISTS (
SELECT 1 FROM "workspace_role_permissions" wrp
WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission
);
--> statement-breakpoint
-- Migrate existing workspace_members to use roleId
UPDATE "workspace_members" wm
SET "roleId" = wr.id
FROM "workspace_roles" wr
WHERE wm."workspaceId" = wr."workspaceId"
AND wm."role"::text = wr."name"
AND wm."roleId" IS NULL;
--> statement-breakpoint
-- Clean up helper function
DROP FUNCTION IF EXISTS generate_public_id();

View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS "user_board_favorites" (
"userId" uuid NOT NULL,
"boardId" bigint NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_board_favorites_userId_boardId_pk" PRIMARY KEY("userId","boardId")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_board_favorites" ADD CONSTRAINT "user_board_favorites_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_board_favorites" ADD CONSTRAINT "user_board_favorites_boardId_board_id_fk" FOREIGN KEY ("boardId") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_board_favorite_user_idx" ON "user_board_favorites" USING btree ("userId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_board_favorite_board_idx" ON "user_board_favorites" USING btree ("boardId");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -162,6 +162,20 @@
"when": 1768858977000,
"tag": "20260119164257_AddShowEmailsToMembersToWorkspace",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1769435949476,
"tag": "20260126135909_AddWorkspaceRoles",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1769983198190,
"tag": "20260201215958_AddUserBoardFavouritesTable",
"breakpoints": true
}
]
}

View File

@@ -26,6 +26,7 @@ import {
comments,
labels,
lists,
userBoardFavorites,
workspaceMembers,
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
@@ -39,17 +40,24 @@ export const getCount = async (db: dbClient) => {
return result[0]?.count ?? 0;
};
export const getAllByWorkspaceId = (
export const getAllByWorkspaceId = async (
db: dbClient,
workspaceId: number,
userId: string,
opts?: { type?: "regular" | "template" },
) => {
return db.query.boards.findMany({
const boardsData = await db.query.boards.findMany({
columns: {
publicId: true,
name: true,
},
with: {
userFavorites: {
where: eq(userBoardFavorites.userId, userId),
columns: {
userId: true,
},
},
lists: {
columns: {
publicId: true,
@@ -72,6 +80,21 @@ export const getAllByWorkspaceId = (
opts?.type ? eq(boards.type, opts.type) : undefined,
),
});
// Transform and sort: favorites first, then alphabetically
return boardsData
.map((board) => ({
...board,
favorite: board.userFavorites.length > 0,
userFavorites: undefined,
}))
.sort((a, b) => {
// Sort favorites first
if (a.favorite && !b.favorite) return -1;
if (!a.favorite && b.favorite) return 1;
// Then alphabetically by name
return a.name.localeCompare(b.name);
});
};
export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
@@ -122,6 +145,7 @@ const buildDueDateWhere = (filters: DueDateFilter[]) => {
export const getByPublicId = async (
db: dbClient,
boardPublicId: string,
userId: string,
filters: {
members: string[];
labels: string[];
@@ -173,6 +197,12 @@ export const getByPublicId = async (
visibility: true,
},
with: {
userFavorites: {
where: eq(userBoardFavorites.userId, userId),
columns: {
userId: true,
},
},
workspace: {
columns: {
publicId: true,
@@ -325,6 +355,8 @@ export const getByPublicId = async (
const formattedResult = {
...board,
favorite: board.userFavorites.length > 0,
userFavorites: undefined,
lists: board.lists.map((list) => ({
...list,
cards: list.cards.map((card) => ({
@@ -518,6 +550,7 @@ export const getWithListIdsByPublicId = (
columns: {
id: true,
workspaceId: true,
createdBy: true,
},
with: {
lists: {
@@ -671,6 +704,7 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
columns: {
id: true,
workspaceId: true,
createdBy: true,
},
where: eq(boards.publicId, boardPublicId),
});
@@ -922,3 +956,34 @@ export const createFromSnapshot = async (
return newBoard;
});
};
export const addUserFavorite = async (
db: dbClient,
userId: string,
boardId: number,
) => {
return db
.insert(userBoardFavorites)
.values({
userId,
boardId,
})
.onConflictDoNothing()
.returning();
};
export const removeUserFavorite = async (
db: dbClient,
userId: string,
boardId: number,
) => {
return db
.delete(userBoardFavorites)
.where(
and(
eq(userBoardFavorites.userId, userId),
eq(userBoardFavorites.boardId, boardId)
)
)
.returning();
};

View File

@@ -93,7 +93,7 @@ export const create = async (
index: index,
dueDate: cardInput.dueDate ?? null,
})
.returning({ id: cards.id, listId: cards.listId });
.returning({ id: cards.id, listId: cards.listId, publicId: cards.publicId });
if (!result[0]) throw new Error("Unable to create card");
@@ -424,6 +424,7 @@ export const getWithListAndMembersByPublicId = async (
title: true,
description: true,
dueDate: true,
createdBy: true,
},
with: {
labels: {
@@ -938,7 +939,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
cardPublicId: string,
) => {
const result = await db.query.cards.findFirst({
columns: { id: true },
columns: { id: true, createdBy: true },
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
with: {
list: {
@@ -958,6 +959,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
return result
? {
id: result.id,
createdBy: result.createdBy,
workspaceId: result.list.board.workspaceId,
workspaceVisibility: result.list.board.visibility,
}

View File

@@ -419,7 +419,7 @@ export const getWorkspaceAndListIdByListPublicId = async (
listPublicId: string,
) => {
const result = await db.query.lists.findFirst({
columns: { id: true },
columns: { id: true, createdBy: true },
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
with: {
board: {
@@ -431,6 +431,10 @@ export const getWorkspaceAndListIdByListPublicId = async (
});
return result
? { id: result.id, workspaceId: result.board.workspaceId }
? {
id: result.id,
createdBy: result.createdBy,
workspaceId: result.board.workspaceId,
}
: null;
};

View File

@@ -27,6 +27,7 @@ export const create = async (
workspaceId: number;
createdBy: string;
role: MemberRole;
roleId?: number | null;
status: MemberStatus;
},
) => {
@@ -39,6 +40,7 @@ export const create = async (
workspaceId: memberInput.workspaceId,
createdBy: memberInput.createdBy,
role: memberInput.role,
roleId: memberInput.roleId ?? null,
status: memberInput.status,
})
.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 (
db: dbClient,
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

@@ -6,6 +6,7 @@ import {
ilike,
inArray,
isNull,
asc,
or,
sql,
} from "drizzle-orm";
@@ -18,7 +19,33 @@ import {
workspaceMembers,
workspaces,
} 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) => {
const result = await db
@@ -57,6 +84,22 @@ export const create = async (
});
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({
publicId: generateUID(),
userId: workspaceInput.createdBy,
@@ -64,6 +107,7 @@ export const create = async (
workspaceId: workspace.id,
createdBy: workspaceInput.createdBy,
role: "admin",
roleId: adminRoleId,
status: "active",
});
}
@@ -142,6 +186,8 @@ export const getByPublicIdWithMembers = (
columns: {
id: true,
publicId: true,
name: true,
slug: true,
showEmailsToMembers: true,
},
with: {
@@ -151,8 +197,13 @@ export const getByPublicIdWithMembers = (
email: true,
role: true,
status: true,
createdAt: true,
},
where: isNull(workspaceMembers.deletedAt),
orderBy: (member, { desc }) => [
desc(sql`CASE WHEN ${member.role} = 'admin' THEN 1 ELSE 0 END`),
desc(member.createdAt),
],
with: {
user: {
columns: {
@@ -200,6 +251,7 @@ export const getBySlugWithBoards = (db: dbClient, workspaceSlug: string) => {
name: true,
},
where: and(isNull(boards.deletedAt), eq(boards.visibility, "public")),
orderBy: [asc(boards.name)]
},
},
where: and(

View File

@@ -2,9 +2,11 @@ import { relations, sql } from "drizzle-orm";
import {
bigint,
bigserial,
boolean,
index,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
@@ -67,6 +69,7 @@ export const boards = pgTable(
).enableRLS();
export const boardsRelations = relations(boards, ({ one, many }) => ({
userFavorites: many(userBoardFavorites),
createdBy: one(users, {
fields: [boards.createdBy],
references: [users.id],
@@ -91,3 +94,21 @@ export const boardsRelations = relations(boards, ({ one, many }) => ({
relationName: "boardWorkspace",
}),
}));
export const userBoardFavorites = pgTable(
"user_board_favorites",
{
userId: uuid("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
boardId: bigint("boardId", { mode: "number" })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
},
(table) => ({
pk: primaryKey({ columns: [table.userId, table.boardId] }),
userIdx: index("user_board_favorite_user_idx").on(table.userId),
boardIdx: index("user_board_favorite_board_idx").on(table.boardId),
}),
);

View File

@@ -12,3 +12,4 @@ export * from "./integrations";
export * from "./workspaces";
export * from "./subscriptions";
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

@@ -8,7 +8,7 @@ import {
} from "drizzle-orm/pg-core";
import { apikey } from "./auth";
import { boards } from "./boards";
import { boards, userBoardFavorites } from "./boards";
import { cards } from "./cards";
import { imports } from "./imports";
import { lists } from "./lists";
@@ -84,3 +84,14 @@ export const usersToWorkspacesRelations = relations(
}),
}),
);
export const userBoardFavoritesRelations = relations(userBoardFavorites, ({ one }) => ({
user: one(users, {
fields: [userBoardFavorites.userId],
references: [users.id],
}),
board: one(boards, {
fields: [userBoardFavorites.boardId],
references: [boards.id],
}),
}));

View File

@@ -12,6 +12,7 @@ import {
} from "drizzle-orm/pg-core";
import { boards } from "./boards";
import { workspaceMemberPermissions, workspaceRoles } from "./permissions";
import { subscription } from "./subscriptions";
import { users } from "./users";
@@ -69,6 +70,7 @@ export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
members: many(workspaceMembers),
boards: many(boards),
subscriptions: many(subscription),
roles: many(workspaceRoles),
}));
export const workspaceMembers = pgTable("workspace_members", {
@@ -86,13 +88,18 @@ export const workspaceMembers = pgTable("workspace_members", {
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
// Legacy role enum
role: memberRoleEnum("role").notNull(),
roleId: bigint("roleId", { mode: "number" }).references(
() => workspaceRoles.id,
{ onDelete: "restrict" },
),
status: memberStatusEnum("status").default("invited").notNull(),
}).enableRLS();
export const workspaceMembersRelations = relations(
workspaceMembers,
({ one }) => ({
({ one, many }) => ({
user: one(users, {
fields: [workspaceMembers.userId],
references: [users.id],
@@ -103,6 +110,23 @@ export const workspaceMembersRelations = relations(
references: [workspaces.id],
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 "./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);
}

38
pnpm-lock.yaml generated
View File

@@ -163,6 +163,9 @@ importers:
date-fns:
specifier: ^4.1.0
version: 4.1.0
framer-motion:
specifier: ^12.26.2
version: 12.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
geist:
specifier: ^1.3.1
version: 1.4.2(next@15.5.9(@babel/core@7.28.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
@@ -5482,6 +5485,20 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
framer-motion@12.26.2:
resolution: {integrity: sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
@@ -6594,6 +6611,12 @@ packages:
moo@0.5.2:
resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==}
motion-dom@12.26.2:
resolution: {integrity: sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw==}
motion-utils@12.24.10:
resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==}
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -13955,6 +13978,15 @@ snapshots:
format@0.2.2: {}
framer-motion@12.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
motion-dom: 12.26.2
motion-utils: 12.24.10
tslib: 2.8.1
optionalDependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
fs-extra@10.1.0:
dependencies:
graceful-fs: 4.2.11
@@ -15504,6 +15536,12 @@ snapshots:
moo@0.5.2: {}
motion-dom@12.26.2:
dependencies:
motion-utils: 12.24.10
motion-utils@12.24.10: {}
mri@1.2.0: {}
ms@2.1.3: {}

Some files were not shown because too many files have changed in this diff Show More