Compare commits

..

1 Commits

Author SHA1 Message Date
Henry
8f11eb0085 fix: missing translation for editor placeholder 2025-08-14 21:36:14 +01:00
72 changed files with 4908 additions and 12071 deletions

View File

@@ -138,40 +138,36 @@ pnpm dev
## Environment Variables 🔐 ## Environment Variables 🔐
| Variable | Description | Required | Example | | Variable | Description | Required | Example |
| ----------------------------------------- | -------------------------------------------------------- | ------------------------ | ----------------------------------------------------------- | | -------------------------------- | -------------------------------------------------------- | ------------------ | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` | | `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` | | `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` | | `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` | | `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | No | `resend` | | `SMTP_USER` | SMTP username/email | No | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` | | `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` | | `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` | | `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string | | `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` | | `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` | | `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` | | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` | | `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` | | `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` | | `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` | | `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` | | `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` | | `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` | | `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` | | `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` | | `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` | | `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` | | `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` | | `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` | | `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` | | `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` | | `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` | | `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
See `.env.example` for a complete list of supported environment variables. See `.env.example` for a complete list of supported environment variables.

View File

@@ -24,11 +24,9 @@ mintlify dev
### Publishing Changes ### Publishing Changes
Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard. Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
#### Troubleshooting #### Troubleshooting
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies. - Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
- Page loads as a 404 - Make sure you are running in a folder with `mint.json` - Page loads as a 404 - Make sure you are running in a folder with `mint.json`
#BUMP

View File

@@ -6,8 +6,6 @@ checksums:
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b "%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18 "%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef "%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
"%2410%2Fmonth/singular": cab5e752ed83096e34fca780d90e1fb9
"%248%2Fmonth/singular": 4667034934bb2bc3d569c70b94205b51
1%20user/singular: 3b547431ab12f0fba84307e6a81109d8 1%20user/singular: 3b547431ab12f0fba84307e6a81109d8
A%20powerful%2C%20flexible%20kanban%20app%20that%20helps%20you%20organise%20work%2C%20track%20progress%2C%20and%20deliver%20results%E2%80%94all%20in%20one%20place./singular: d405b83b0d631cb72f4347c10bcbb643 A%20powerful%2C%20flexible%20kanban%20app%20that%20helps%20you%20organise%20work%2C%20track%20progress%2C%20and%20deliver%20results%E2%80%94all%20in%20one%20place./singular: d405b83b0d631cb72f4347c10bcbb643
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4 Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
@@ -28,12 +26,11 @@ checksums:
added%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: fb6a852c50c1629f47c6f155fc93fdee added%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: fb6a852c50c1629f47c6f155fc93fdee
added%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: bdd202da20b1fffbec21792c5453f90c added%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: bdd202da20b1fffbec21792c5453f90c
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
Adding%20a%20new%20member%20will%20cost%20an%20additional%20%7Bprice%7D%20(%7BbillingType%7D)%20per%20seat./singular: 12e88573028306110fbc15ef1e714892
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
All%20seats%20are%20free%20during%20the%20beta%20until%20Sept%201st!/singular: 015cec3899cf99e37c3cf231d432e31d
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135 Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40 An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
API%20keys/singular: 07f3620a30e08136f0b072c9af8d1eef API%20keys/singular: 07f3620a30e08136f0b072c9af8d1eef
API%20Reference/singular: 7dcc877064bfdf889ec55dbc9e06a242 API%20Reference/singular: 7dcc877064bfdf889ec55dbc9e06a242
Applicants/singular: 4babd8331a1441e91087f855f43e57f8 Applicants/singular: 4babd8331a1441e91087f855f43e57f8
@@ -49,13 +46,10 @@ checksums:
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457 Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7 Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
billed%20annually/singular: c782a8085ffee456bfc56c34ccc5e71c
billed%20monthly/singular: 793f2728acf06e9f06d6cdc26dfcf0cd
Billing/singular: b01dbdd049ebbd4a349fa64d6ce65a3b Billing/singular: b01dbdd049ebbd4a349fa64d6ce65a3b
Billing%20portal/singular: dc3afa14ffe19f5920992a0c9d49d525 Billing%20portal/singular: dc3afa14ffe19f5920992a0c9d49d525
Blog%20Post/singular: 38e027f212be445dc530e427fc642a20 Blog%20Post/singular: 38e027f212be445dc530e427fc642a20
Board/singular: 101ff39aab674c033c15ab978d0d8bac Board/singular: 101ff39aab674c033c15ab978d0d8bac
Board%20analytics%20(coming%20soon)/singular: 3f648e6fe3849ee2ac5cdf410ac9ea06
Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca
Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b
Board%20not%20found/singular: 5af9844595fa8aed5985066620435515 Board%20not%20found/singular: 5af9844595fa8aed5985066620435515
@@ -73,7 +67,6 @@ checksums:
Cancel/singular: 2e2a849c2223911717de8caa2c71bade Cancel/singular: 2e2a849c2223911717de8caa2c71bade
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3 Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
Change%20Password/singular: a552fc5c4189ebc3e2e6018edda7d18f
Change%20the%20language%20of%20the%20app./singular: fb20db145e28ed44aba89c146ded7bfc Change%20the%20language%20of%20the%20app./singular: fb20db145e28ed44aba89c146ded7bfc
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6 Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
@@ -88,7 +81,7 @@ checksums:
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33 completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673 completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416 Confirm%20URL%20change/singular: e14b4ce081d1ef6194b37e1814e97b39
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213 Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d
Connect%20your%20Trello%20account%20to%20import%20boards./singular: 84a510a7485f43e43f2191bd161171d7 Connect%20your%20Trello%20account%20to%20import%20boards./singular: 84a510a7485f43e43f2191bd161171d7
@@ -111,9 +104,8 @@ checksums:
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
Critical/singular: eb327cd411b50aee954f8d1d215d003a Critical/singular: eb327cd411b50aee954f8d1d215d003a
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42 Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
Custom%20workspace%20URL/singular: 7ba841d0946eb04fa3d17d74365be37b Custom%20URLs%20are%20a%20premium%20feature.%20You'll%20be%20directed%20to%20upgrade%20your%20account./singular: b52e4f6122cf739c9551d29beb89b25e
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2 Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9 Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9
@@ -148,13 +140,9 @@ checksums:
email/singular: f31eb214738e037d58e26149797739df email/singular: f31eb214738e037d58e26149797739df
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5 Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31 Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df
Enter%20your%20new%20password/singular: c67251e3002b68bc20a7cf5de23e43ac
Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae
Error%20Changing%20Password/singular: ebecb5c1b72ba4b063117241f5ba4f2d
Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643 Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643
Error%20deleting%20label/singular: 94387e3a45ec768ae7715701ae00136e Error%20deleting%20label/singular: 94387e3a45ec768ae7715701ae00136e
Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26 Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26
@@ -165,7 +153,6 @@ checksums:
Error%20updating%20workspace%20description/singular: 7a9fd7c69b90ab848ee1f1d4a4995381 Error%20updating%20workspace%20description/singular: 7a9fd7c69b90ab848ee1f1d4a4995381
Error%20updating%20workspace%20name/singular: 46025f42a7d5eff689e9cb8bed14a1e4 Error%20updating%20workspace%20name/singular: 46025f42a7d5eff689e9cb8bed14a1e4
Error%20updating%20workspace%20URL/singular: 9da6c012e1b512d8c7fbd812626c4675 Error%20updating%20workspace%20URL/singular: 9da6c012e1b512d8c7fbd812626c4675
Error%20upgrading%20subscription/singular: 57cc47581c7faaffb3b1721b6352b576
Error%20uploading%20profile%20image/singular: 0ba02753a797a74c6ea7b622c9f69c71 Error%20uploading%20profile%20image/singular: 0ba02753a797a74c6ea7b622c9f69c71
Everything%20in%20the%20free%20plan%2C%20plus%3A/singular: 62b44c4973b92b806c69a4b15e0256dc Everything%20in%20the%20free%20plan%2C%20plus%3A/singular: 62b44c4973b92b806c69a4b15e0256dc
Everything%20you%20need%2C%20free%20forever.%20Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20Upgrade%20any%20time./singular: fa21632ab1468edf10acda2fe7b71323 Everything%20you%20need%2C%20free%20forever.%20Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20Upgrade%20any%20time./singular: fa21632ab1468edf10acda2fe7b71323
@@ -181,7 +168,6 @@ checksums:
Find%20answers%20to%20common%20questions%20about%20the%20project.%20Can't%20find%20what%20you're%20looking%20for%3F%20Feel%20free%20to%20%3C0%3Econtact%20us%3C%2F0%3E./singular: 4d99230dad703bf2d71ba670c2f79d3d Find%20answers%20to%20common%20questions%20about%20the%20project.%20Can't%20find%20what%20you're%20looking%20for%3F%20Feel%20free%20to%20%3C0%3Econtact%20us%3C%2F0%3E./singular: 4d99230dad703bf2d71ba670c2f79d3d
For%20long-term%20sustainability%2C%20we%20recognise%20all%20good%20open%20source%20projects%20need%20a%20source%20of%20revenue.%20Ours%20comes%20from%20the%20paid%20cloud%20offering%20for%20workspaces%20with%20multiple%20users%20(free%20during%20the%20beta)%20and%20for%20custom%20workspace%20slugs.%20There's%20no%20obligation%20to%20use%20it%2C%20and%20you%20can%20self-host%20the%20software%20on%20your%20own%20infrastructure%20free%20of%20charge./singular: 4eedacd4311ff69bf0fd87083c90a268 For%20long-term%20sustainability%2C%20we%20recognise%20all%20good%20open%20source%20projects%20need%20a%20source%20of%20revenue.%20Ours%20comes%20from%20the%20paid%20cloud%20offering%20for%20workspaces%20with%20multiple%20users%20(free%20during%20the%20beta)%20and%20for%20custom%20workspace%20slugs.%20There's%20no%20obligation%20to%20use%20it%2C%20and%20you%20can%20self-host%20the%20software%20on%20your%20own%20infrastructure%20free%20of%20charge./singular: 4eedacd4311ff69bf0fd87083c90a268
Free/singular: 0326365539c004f6088656f692602078 Free/singular: 0326365539c004f6088656f692602078
Free%20Plan/singular: c995acccc1e3cba379786892a5b4d4e5
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
Full-time/singular: fe6e201f6676ae354111435cf6c76910 Full-time/singular: fe6e201f6676ae354111435cf6c76910
Fun/singular: 395bdc48e943762b6cde649f4a96771d Fun/singular: 395bdc48e943762b6cde649f4a96771d
@@ -222,7 +208,6 @@ checksums:
Invite/singular: 181884cea804cbde665f160811ee7ad0 Invite/singular: 181884cea804cbde665f160811ee7ad0
Invite%20another/singular: acb543563dab7edbcf46060a53ade3a3 Invite%20another/singular: acb543563dab7edbcf46060a53ade3a3
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7 Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
Inviting%20members%20requires%20a%20Team%20Plan.%20You'll%20be%20redirected%20to%20upgrade%20your%20workspace./singular: 03eeed2d715e770259f722ba48a61ab3
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223 Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766 Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
@@ -230,8 +215,6 @@ checksums:
Labels/singular: 6f15627a90002323eac018274b6922d6 Labels/singular: 6f15627a90002323eac018274b6922d6
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390 Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
Language/singular: 277fd1a41cc237a437cd1d5e4a80463b Language/singular: 277fd1a41cc237a437cd1d5e4a80463b
Launch%20offer/singular: 624592ba2ecf647a3fe60d493c1ee71a
Launch%20offer%3A%20unlimited%20seats%20for%20just%20%2429%2Fmonth%20with%20Pro/singular: 235d1cda21811c296afdc0dcc438cc24
Learning/singular: 90e91da3f870f065f2da9aa9adf53514 Learning/singular: 90e91da3f870f065f2da9aa9adf53514
Legal/singular: 00d3f08da61a21887009819cf93fb414 Legal/singular: 00d3f08da61a21887009819cf93fb414
License/singular: ac2bcac75151e2fcd9eb768cea275a77 License/singular: ac2bcac75151e2fcd9eb768cea275a77
@@ -250,7 +233,6 @@ checksums:
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32 Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88 Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
Monthly/singular: 818f1192e32bb855597f930d3e78806e Monthly/singular: 818f1192e32bb855597f930d3e78806e
monthly%20billing/singular: e228107df80015377112d41f4f155cb3
moved%20the%20card%20from%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%3C1%3E%7B1%7D%3C%2F1%3E/singular: 11edf0427766c26db541d46379dc3c16 moved%20the%20card%20from%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%3C1%3E%7B1%7D%3C%2F1%3E/singular: 11edf0427766c26db541d46379dc3c16
moved%20the%20card%20to%20another%20list/singular: 2e8c41bfafb42fa299ce56e4300205ff moved%20the%20card%20to%20another%20list/singular: 2e8c41bfafb42fa299ce56e4300205ff
Name/singular: 9368b5a047572b6051f334af5aa76819 Name/singular: 9368b5a047572b6051f334af5aa76819
@@ -262,8 +244,6 @@ checksums:
New%20import/singular: e8fddfe15dd170277df919e5d37efd47 New%20import/singular: e8fddfe15dd170277df919e5d37efd47
New%20label/singular: 890040da52a88c16c249a4ac78980f81 New%20label/singular: 890040da52a88c16c249a4ac78980f81
New%20list/singular: 146c2e642b11056a0b07ed65733e4178 New%20list/singular: 146c2e642b11056a0b07ed65733e4178
New%20password%20is%20required/singular: f88427a4cca058631199a4384cb5622e
New%20password%20must%20be%20different%20from%20current%20password/singular: db33013fcc6c40995cf8a37deb3d5667
New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09 New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09
New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7 Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
@@ -280,15 +260,11 @@ checksums:
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56 Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6 Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
Password%20Changed/singular: 1fcebe9ddb46f722a57f195efddc695d
Password%20must%20be%20at%20least%208%20characters/singular: 4c30501d085eaccea47af34212bb26a7
Passwords%20do%20not%20match/singular: 37ca1f4e0afc9a0b8e9617f767103c92
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86 Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039 Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360 per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
Planning/singular: 353f58c75248275fe091740607501610 Planning/singular: 353f58c75248275fe091740607501610
Please%20confirm%20your%20new%20password/singular: 43d0307b9307238cc6c199cfa66caff2
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932 Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
@@ -298,8 +274,6 @@ checksums:
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1 Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8 Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
Private/singular: d740d0660e68c61db1853489356030ae Private/singular: d740d0660e68c61db1853489356030ae
Pro%20Plan/singular: 76a09fd843b496610f8cf1dced417b62
Pro%20Plan%20%E2%88%9E/singular: a898be9e4610706b9b8146a209e8ba16
Profile%20image%20updated/singular: 1ba6609b50bc217a36ab659563e78dae Profile%20image%20updated/singular: 1ba6609b50bc217a36ab659563e78dae
Profile%20picture/singular: d9b5e595877ffe2557486a987dd28274 Profile%20picture/singular: d9b5e595877ffe2557486a987dd28274
Public/singular: c834f11dab0881632aded3f01d4f8fa9 Public/singular: c834f11dab0881632aded3f01d4f8fa9
@@ -348,16 +322,13 @@ checksums:
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875 Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
Success/singular: c43827becada6750f7a25890905f38b9 Success/singular: c43827becada6750f7a25890905f38b9
Supercharge%20your%20workspace%20for%20just%20%2429%2Fmonth.%20Here's%20what%20you'll%20get%3A/singular: 21af8119141afcce5f12da489f34db6a
Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63 Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63
Support%20the%20development%20of%20the%20project/singular: c63bab228ab2b1be4370c668a99cd278 Support%20the%20development%20of%20the%20project/singular: c63bab228ab2b1be4370c668a99cd278
System/singular: 803281c327a02a2feba182f4a2b38311 System/singular: 803281c327a02a2feba182f4a2b38311
Team%20Plan/singular: 3f0ae9c914f16ceb9a0d7db868f4aa20
Teams/singular: b63448c05270497973ac4407047dae02 Teams/singular: b63448c05270497973ac4407047dae02
Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63 Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28 Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768 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%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%20open%20source%20%3C0%2F%3E%20alternative%20to%20Trello/singular: 692cb2e8a8e610953c996826beed45a2 The%20open%20source%20%3C0%2F%3E%20alternative%20to%20Trello/singular: 692cb2e8a8e610953c996826beed45a2
The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
@@ -404,7 +375,6 @@ checksums:
Unlimited%20cards/singular: f44c1050c4d6aa39c8cdf5f4a7d12577 Unlimited%20cards/singular: f44c1050c4d6aa39c8cdf5f4a7d12577
Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040 Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362 Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
Unlimited%20members/singular: f2949ca2dc18b0063af92e60779abb65
Update/singular: 079fc039262fd31b10532929685c2d1b Update/singular: 079fc039262fd31b10532929685c2d1b
Update%20label/singular: 97880b503dfe956941c5f23025a1c102 Update%20label/singular: 97880b503dfe956941c5f23025a1c102
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
@@ -412,8 +382,6 @@ checksums:
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21 updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e
Upgrade/singular: 63c3b52882e0d779859307d672c178c2 Upgrade/singular: 63c3b52882e0d779859307d672c178c2
Upgrade%20to%20Pro/singular: 972773025e763ddf53273df6d37a729a
Upgrade%20to%20Team%20Plan/singular: 40e2ef151266937f8bbe63b3d361f645
Urgent/singular: e79afcd4398e52e10e7e8243a7eecabe Urgent/singular: e79afcd4398e52e10e7e8243a7eecabe
URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: 875d37d2d920f98ef60a314eda8a5d9a URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: 875d37d2d920f98ef60a314eda8a5d9a
URL%20cannot%20exceed%2024%20characters/singular: 395c25732b84ac1f5b63ee310da08e93 URL%20cannot%20exceed%2024%20characters/singular: 395c25732b84ac1f5b63ee310da08e93
@@ -452,17 +420,14 @@ checksums:
Writing/singular: 4f5f3ee3320c252921f80c42653c5d49 Writing/singular: 4f5f3ee3320c252921f80c42653c5d49
Yearly/singular: 87f43e016c19cb25860f456549a2f431 Yearly/singular: 87f43e016c19cb25860f456549a2f431
Yes%2C%20we%20offer%20an%20forever%20free%20plan%20for%20individual%20use.%20No%20restrictions%2C%20no%20paywalls%2C%20no%20limits./singular: 963cee303d0ff13c1719f4575e774fe3 Yes%2C%20we%20offer%20an%20forever%20free%20plan%20for%20individual%20use.%20No%20restrictions%2C%20no%20paywalls%2C%20no%20limits./singular: 963cee303d0ff13c1719f4575e774fe3
You%20are%20about%20to%20change%20your%20password./singular: df4783d593505766d78c5bc08d3d48ac
You%20can%20get%20a%20custom%20workspace%20URL%2C%20like%20%3C0%3Ekan.bn%2Fkan%3C%2F0%3E%2C%20by%20going%20into%20your%20%3C1%3Eworkspace%20settings%3C%2F1%3E%20and%20purchasing%20a%20pro%20workspace%20subscription.%20All%20subscriptions%20help%20fund%20the%20development%20of%20the%20project!/singular: 41dd145ef56f539e12dc064a9807c660 You%20can%20get%20a%20custom%20workspace%20URL%2C%20like%20%3C0%3Ekan.bn%2Fkan%3C%2F0%3E%2C%20by%20going%20into%20your%20%3C1%3Eworkspace%20settings%3C%2F1%3E%20and%20purchasing%20a%20pro%20workspace%20subscription.%20All%20subscriptions%20help%20fund%20the%20development%20of%20the%20project!/singular: 41dd145ef56f539e12dc064a9807c660
You%20can%20invite%20team%20members%20by%20clicking%20the%20%22Invite%22%20button%20in%20the%20top%20right%20corner%20of%20the%20%3C0%3Emembers%20page%3C%2F0%3E%20and%20entering%20their%20email%20address.%20They%20will%20receive%20an%20email%20with%20a%20link%20to%20join%20the%20workspace./singular: 47e125eb9b4c11cab5a2f4b3ab08e883 You%20can%20invite%20team%20members%20by%20clicking%20the%20%22Invite%22%20button%20in%20the%20top%20right%20corner%20of%20the%20%3C0%3Emembers%20page%3C%2F0%3E%20and%20entering%20their%20email%20address.%20They%20will%20receive%20an%20email%20with%20a%20link%20to%20join%20the%20workspace./singular: 47e125eb9b4c11cab5a2f4b3ab08e883
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9 You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
You%20have%20unlimited%20seats%20with%20your%20Pro%20Plan.%20There%20is%20no%20additional%20charge%20for%20new%20members!/singular: e3dc59a5ba7211cd3d8516b3a79d85ca
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868 Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4 Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4
Your%20password%20has%20been%20changed./singular: 8f730f68d52b7efa727aa0784282ebcb
Your%20profile%20image%20has%20been%20updated./singular: 099e1780348cd7bf617721344371a430 Your%20profile%20image%20has%20been%20updated./singular: 099e1780348cd7bf617721344371a430
Your%20Trello%20account%20has%20been%20disconnected./singular: 126aeeb73f7e4cad338056e4c933f39b Your%20Trello%20account%20has%20been%20disconnected./singular: 126aeeb73f7e4cad338056e4c933f39b
Your%20Trello%20account%20is%20connected./singular: 0499d193001fce2fbec445ae034ce512 Your%20Trello%20account%20is%20connected./singular: 0499d193001fce2fbec445ae034ce512

View File

@@ -31,12 +31,12 @@ const config = {
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
hostname: hostname: process.env.S3_FORCE_PATH_STYLE === "true"
env("S3_FORCE_PATH_STYLE") === "true" ? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}` : `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
}, },
{ {
protocol: "http", protocol: "http",
@@ -54,16 +54,4 @@ const config = {
}, },
}; };
// Only allow external images when OIDC is configured (for OIDC provider avatars)
if (
env("OIDC_CLIENT_ID") &&
env("OIDC_CLIENT_SECRET") &&
env("OIDC_DISCOVERY_URL")
) {
config.images?.remotePatterns?.push({
protocol: "https",
hostname: "**",
});
}
export default config; export default config;

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "pnpm with-env next build", "build": "pnpm with-env next build",
"clean": "git clean -xdf .cache .next .turbo node_modules", "clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "pnpm with-env next dev", "dev": "pnpm with-env next dev | pino-pretty",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint", "lint": "eslint",
"start": "pnpm with-env next start", "start": "pnpm with-env next start",
@@ -49,7 +49,6 @@
"next": "^15.3.4", "next": "^15.3.4",
"next-logger": "^5.0.1", "next-logger": "^5.0.1",
"next-runtime-env": "^1.7.2", "next-runtime-env": "^1.7.2",
"next-themes": "^0.4.6",
"nextjs-cors": "^2.2.0", "nextjs-cors": "^2.2.0",
"pino": "^9.6.0", "pino": "^9.6.0",
"posthog-js": "^1.254.0", "posthog-js": "^1.254.0",

View File

@@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { import {
FaApple, FaApple,
@@ -16,7 +16,6 @@ import {
FaGoogle, FaGoogle,
FaLinkedin, FaLinkedin,
FaMicrosoft, FaMicrosoft,
FaOpenid,
FaReddit, FaReddit,
FaSpotify, FaSpotify,
FaTiktok, FaTiktok,
@@ -34,8 +33,6 @@ import Button from "~/components/Button";
import Input from "~/components/Input"; import Input from "~/components/Input";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
type AuthProvider = SocialProvider | "oidc";
interface FormValues { interface FormValues {
name?: string; name?: string;
email: string; email: string;
@@ -144,28 +141,16 @@ const availableSocialProviders = {
name: "Zoom", name: "Zoom",
icon: SiZoom, icon: SiZoom,
}, },
oidc: {
id: "oidc",
name: "OIDC",
icon: FaOpenid,
},
}; };
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) { export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
const [isLoginWithProviderPending, setIsLoginWithProviderPending] = const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
useState<null | AuthProvider>(null); useState<null | SocialProvider>(null);
const [isCredentialsEnabled, setIsCredentialsEnabled] = useState(false); const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false); const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(null);
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const oidcProviderName = "OIDC";
// Safely get environment variables on client side to avoid hydration mismatch
useEffect(() => {
const credentialsAllowed =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
setIsCredentialsEnabled(credentialsAllowed);
}, []);
const { const {
register, register,
@@ -241,26 +226,13 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
setIsLoginWithEmailPending(false); setIsLoginWithEmailPending(false);
}; };
const handleLoginWithProvider = async (provider: AuthProvider) => { const handleLoginWithProvider = async (provider: SocialProvider) => {
setIsLoginWithProviderPending(provider); setIsLoginWithProviderPending(provider);
setLoginError(null); setLoginError(null);
const { error } = await authClient.signIn.social({
let error; provider,
if (provider === "oidc") { callbackURL: "/boards",
// Use oauth2 signin for OIDC provider });
const result = await authClient.signIn.oauth2({
providerId: "oidc",
callbackURL: "/boards",
});
error = result.error;
} else {
// Use social signin for traditional social providers
const result = await authClient.signIn.social({
provider,
callbackURL: "/boards",
});
error = result.error;
}
setIsLoginWithProviderPending(null); setIsLoginWithProviderPending(null);
@@ -288,16 +260,13 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
return ( return (
<Button <Button
key={key} key={key}
onClick={() => handleLoginWithProvider(key as AuthProvider)} onClick={() => handleLoginWithProvider(key as SocialProvider)}
isLoading={isLoginWithProviderPending === key} isLoading={isLoginWithProviderPending === key}
iconLeft={<provider.icon />} iconLeft={<provider.icon />}
fullWidth fullWidth
size="lg" size="lg"
> >
<Trans> <Trans>Continue with {provider.name}</Trans>
Continue with{" "}
{key === "oidc" ? oidcProviderName : provider.name}
</Trans>
</Button> </Button>
); );
})} })}

View File

@@ -55,59 +55,51 @@ export default function CheckboxDropdown({
const renderMenuItems = (items: Item[], groupKey: string | null) => ( const renderMenuItems = (items: Item[], groupKey: string | null) => (
<> <>
{items.length > 0 ? ( {items.map((item) => (
items.map((item) => ( <Menu.Item key={item.key}>
<Menu.Item key={item.key}> <div
<div className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300" onClick={(e) => {
onClick={(e) => { e.preventDefault();
e.preventDefault(); handleSelect(groupKey, { key: item.key, value: item.value });
handleSelect(groupKey, { key: item.key, value: item.value }); }}
}} >
<input
id={item.key}
name={item.key}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={() =>
handleSelect(groupKey, { key: item.key, value: item.value })
}
checked={item.selected}
/>
{item.leftIcon && (
<span className="ml-3 flex items-center">{item.leftIcon}</span>
)}
<label
htmlFor={item.key}
className="ml-3 text-[12px] text-dark-900"
> >
<input {item.value}
id={item.key} </label>
name={item.key} {handleEdit && (
type="checkbox" <button
className="h-[14px] w-[14px] rounded bg-transparent" type="button"
onClick={(event) => event.stopPropagation()} className="invisible ml-auto group-hover:visible"
onChange={() => onClick={(event) => {
handleSelect(groupKey, { key: item.key, value: item.value }) event.preventDefault();
} event.stopPropagation();
checked={item.selected} handleEdit(item.key);
/> }}
{item.leftIcon && (
<span className="ml-3 flex items-center">{item.leftIcon}</span>
)}
<label
htmlFor={item.key}
className="ml-3 text-[12px] text-dark-900"
> >
{item.value} <HiEllipsisHorizontal size={20} className="text-dark-900" />
</label> </button>
{handleEdit && ( )}
<button
type="button"
className="invisible ml-auto group-hover:visible"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleEdit(item.key);
}}
>
<HiEllipsisHorizontal size={20} className="text-dark-900" />
</button>
)}
</div>
</Menu.Item>
))
) : (
!handleCreate && (
<div className="flex items-center p-2 text-[12px] text-dark-900">
No items
</div> </div>
) </Menu.Item>
)} ))}
{handleCreate && ( {handleCreate && (
<button <button
type="button" type="button"

View File

@@ -10,7 +10,7 @@ import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside"; import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useTheme } from "next-themes"; import { useTheme } from "~/providers/theme";
import { WorkspaceProvider } from "~/providers/workspace"; import { WorkspaceProvider } from "~/providers/workspace";
import FeedbackModal from "./FeedbackModal"; import FeedbackModal from "./FeedbackModal";
import Modal from "./modal"; import Modal from "./modal";
@@ -41,7 +41,7 @@ export default function Dashboard({
rightPanel, rightPanel,
hasRightPanel = false, hasRightPanel = false,
}: DashboardProps) { }: DashboardProps) {
const { theme } = useTheme(); const theme = useTheme();
const { modalContentType } = useModal(); const { modalContentType } = useModal();
const { data: session, isPending: sessionLoading } = authClient.useSession(); const { data: session, isPending: sessionLoading } = authClient.useSession();
@@ -90,7 +90,7 @@ export default function Dashboard({
} }
}); });
const isDarkMode = theme === "dark"; const isDarkMode = theme.activeTheme === "dark";
return ( return (
<> <>

View File

@@ -31,7 +31,6 @@ export function NewWorkspaceForm() {
description: values.description, description: values.description,
slug: values.slug, slug: values.slug,
plan: values.plan, plan: values.plan,
role: "admin",
}); });
closeModal(); closeModal();
} }

View File

@@ -18,7 +18,7 @@ import settingsIconLight from "~/assets/settings-light.json";
import ReactiveButton from "~/components/ReactiveButton"; import ReactiveButton from "~/components/ReactiveButton";
import UserMenu from "~/components/UserMenu"; import UserMenu from "~/components/UserMenu";
import WorkspaceMenu from "~/components/WorkspaceMenu"; import WorkspaceMenu from "~/components/WorkspaceMenu";
import { useTheme } from "next-themes"; import { useTheme } from "~/providers/theme";
interface SideNavigationProps { interface SideNavigationProps {
user: UserType; user: UserType;
@@ -59,9 +59,9 @@ export default function SideNavigation({
const { pathname } = router; const { pathname } = router;
const { theme, resolvedTheme } = useTheme(); const { activeTheme } = useTheme();
const isDarkMode = resolvedTheme === "dark"; const isDarkMode = activeTheme === "dark";
const navigation = [ const navigation = [
{ {

View File

@@ -1,22 +1,23 @@
import { CgDarkMode } from "react-icons/cg"; import { CgDarkMode } from "react-icons/cg";
import { useTheme } from "next-themes";
import { useTheme } from "~/providers/theme";
const ThemeToggle = () => { const ThemeToggle = () => {
const { theme, setTheme } = useTheme(); const { themePreference, switchTheme } = useTheme();
const toggleTheme = () => { const toggleTheme = () => {
setTheme(theme === "light" ? "dark" : "light"); switchTheme(themePreference === "light" ? "dark" : "light");
}; };
return ( return (
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100" className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} theme`} aria-label={`Switch to ${themePreference === "light" ? "dark" : "light"} theme`}
> >
<CgDarkMode <CgDarkMode
className={`h-4 w-4 text-light-900 transition-transform duration-200 dark:text-dark-900 ${ className={`h-4 w-4 text-light-900 transition-transform duration-200 dark:text-dark-900 ${
theme === "dark" ? "rotate-180" : "rotate-0" themePreference === "dark" ? "rotate-180" : "rotate-0"
}`} }`}
/> />
</button> </button>

View File

@@ -10,7 +10,7 @@ import { authClient } from "@kan/auth/client";
import { useIsMobile } from "~/hooks/useMediaQuery"; import { useIsMobile } from "~/hooks/useMediaQuery";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useTheme } from "next-themes"; import { useTheme } from "~/providers/theme";
import { getAvatarUrl } from "~/utils/helpers"; import { getAvatarUrl } from "~/utils/helpers";
interface UserMenuProps { interface UserMenuProps {
@@ -29,7 +29,7 @@ export default function UserMenu({
onCloseSideNav, onCloseSideNav,
}: UserMenuProps) { }: UserMenuProps) {
const router = useRouter(); const router = useRouter();
const { theme, setTheme } = useTheme(); const { themePreference, switchTheme } = useTheme();
const { openModal } = useModal(); const { openModal } = useModal();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -127,12 +127,12 @@ export default function UserMenu({
</div> </div>
<Menu.Item> <Menu.Item>
<button <button
onClick={() => setTheme("system")} onClick={() => switchTheme("system")}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400" className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
> >
<span <span
className={twMerge( className={twMerge(
theme === "system" ? "visible" : "invisible", themePreference === "system" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900", "mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)} )}
/> />
@@ -141,12 +141,12 @@ export default function UserMenu({
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>
<button <button
onClick={() => setTheme("dark")} onClick={() => switchTheme("dark")}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400" className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
> >
<span <span
className={twMerge( className={twMerge(
theme === "dark" ? "visible" : "invisible", themePreference === "dark" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900", "mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)} )}
/> />
@@ -155,12 +155,12 @@ export default function UserMenu({
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>
<button <button
onClick={() => setTheme("light")} onClick={() => switchTheme("light")}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400" className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
> >
<span <span
className={twMerge( className={twMerge(
theme === "light" ? "visible" : "invisible", themePreference === "light" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900", "mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)} )}
/> />

View File

@@ -20,12 +20,12 @@ export default function WorkspaceMenu({
<Menu as="div" className="relative inline-block w-full pb-3 text-left"> <Menu as="div" className="relative inline-block w-full pb-3 text-left">
<div> <div>
{isLoading ? ( {isLoading ? (
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}> <div className={twMerge("mb-1", isCollapsed && "md:flex md:p-1.5")}>
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" /> <div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
<div <div
className={twMerge( className={twMerge(
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200", "ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
isCollapsed && "md:hidden", isCollapsed && "hidden md:block",
)} )}
/> />
</div> </div>

View File

@@ -24,7 +24,7 @@ export const env = createEnv({
s.split(",").every((l) => z.string().url().safeParse(l).success), s.split(",").every((l) => z.string().url().safeParse(l).success),
) )
.optional(), .optional(),
POSTGRES_URL: z.string().url().optional().or(z.literal("")), POSTGRES_URL: z.string().url().optional().or(z.literal('')),
TRELLO_APP_API_KEY: z.string().optional(), TRELLO_APP_API_KEY: z.string().optional(),
TRELLO_APP_SECRET: z.string().optional(), TRELLO_APP_SECRET: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(), STRIPE_SECRET_KEY: z.string().optional(),
@@ -51,10 +51,6 @@ export const env = createEnv({
VK_CLIENT_SECRET: z.string().optional(), VK_CLIENT_SECRET: z.string().optional(),
LINKEDIN_CLIENT_ID: z.string().optional(), LINKEDIN_CLIENT_ID: z.string().optional(),
LINKEDIN_CLIENT_SECRET: z.string().optional(), LINKEDIN_CLIENT_SECRET: z.string().optional(),
// Generic OIDC Provider
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
OIDC_DISCOVERY_URL: z.string().optional(),
REDDIT_CLIENT_ID: z.string().optional(), REDDIT_CLIENT_ID: z.string().optional(),
REDDIT_CLIENT_SECRET: z.string().optional(), REDDIT_CLIENT_SECRET: z.string().optional(),
ROBLOX_CLIENT_ID: z.string().optional(), ROBLOX_CLIENT_ID: z.string().optional(),
@@ -105,13 +101,6 @@ export const env = createEnv({
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false", (s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
) )
.optional(), .optional(),
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
}, },
/** /**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away. * Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
@@ -130,8 +119,6 @@ export const env = createEnv({
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP, NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT, process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY:
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY,
}, },
skipValidation: skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === "lint", !!process.env.CI || process.env.npm_lifecycle_event === "lint",

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

@@ -14,7 +14,7 @@ import { useEffect } from "react";
import { LinguiProviderWrapper } from "~/providers/lingui"; import { LinguiProviderWrapper } from "~/providers/lingui";
import { ModalProvider } from "~/providers/modal"; import { ModalProvider } from "~/providers/modal";
import { PopupProvider } from "~/providers/popup"; import { PopupProvider } from "~/providers/popup";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "~/providers/theme";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
const jakarta = Plus_Jakarta_Sans({ const jakarta = Plus_Jakarta_Sans({
@@ -81,9 +81,9 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
<script src="/__ENV.js" /> <script src="/__ENV.js" />
<main className="font-sans"> <main className="font-sans">
<LinguiProviderWrapper> <LinguiProviderWrapper>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider>
<ModalProvider> <ModalProvider>
<PopupProvider> <PopupProvider>
{posthogKey ? ( {posthogKey ? (
<PostHogProvider client={posthog}> <PostHogProvider client={posthog}>
{getLayout(<Component {...pageProps} />)} {getLayout(<Component {...pageProps} />)}
@@ -91,8 +91,8 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
) : ( ) : (
getLayout(<Component {...pageProps} />) getLayout(<Component {...pageProps} />)
)} )}
</PopupProvider> </PopupProvider>
</ModalProvider> </ModalProvider>
</ThemeProvider> </ThemeProvider>
</LinguiProviderWrapper> </LinguiProviderWrapper>
</main> </main>

View File

@@ -3,7 +3,6 @@ import { env } from "next-runtime-env";
import { z } from "zod"; import { z } from "zod";
import { createNextApiContext } from "@kan/api/trpc"; import { createNextApiContext } from "@kan/api/trpc";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
@@ -41,22 +40,17 @@ export default async function handler(
const body = req.body as CheckoutSessionRequest; const body = req.body as CheckoutSessionRequest;
const { successUrl, cancelUrl, slug, workspacePublicId } = body; const { successUrl, cancelUrl, slug, workspacePublicId } = body;
if (!successUrl || !cancelUrl || !workspacePublicId) { if (!successUrl || !cancelUrl || !slug || !workspacePublicId) {
return res.status(400).json({ error: "Missing required fields" }); return res.status(400).json({ error: "Missing required fields" });
} }
if (slug) { const slugResult = workspaceSlugSchema.safeParse(slug);
const slugResult = workspaceSlugSchema.safeParse(slug);
if (!slugResult.success) { if (!slugResult.success) {
return new Response( return new Response(JSON.stringify({ error: "Invalid workspace slug" }), {
JSON.stringify({ error: "Invalid workspace slug" }), status: 400,
{ headers: { "Content-Type": "application/json" },
status: 400, });
headers: { "Content-Type": "application/json" },
},
);
}
} }
const workspace = await workspaceRepo.getAllByUserId(db, user.id); const workspace = await workspaceRepo.getAllByUserId(db, user.id);
@@ -72,37 +66,20 @@ export default async function handler(
}); });
} }
const subscription = await subscriptionRepo.create(db, {
plan: "pro",
referenceId: workspacePublicId,
userId: user.id,
stripeCustomerId: user.stripeCustomerId ?? "",
status: "incomplete",
});
const subscriptionId = subscription?.id;
if (!subscriptionId) {
return res.status(500).json({ error: "Error creating subscription" });
}
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
mode: "subscription", mode: "subscription",
line_items: [ line_items: [
{ {
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID, price: process.env.STRIPE_PRO_PLAN_PRICE_ID,
quantity: 1, quantity: 1,
}, },
], ],
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`, success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`, cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
client_reference_id: workspacePublicId,
customer: user.stripeCustomerId ?? undefined, customer: user.stripeCustomerId ?? undefined,
metadata: { metadata: {
...(slug && { workspaceSlug: slug }), workspaceSlug: slug,
workspacePublicId, workspacePublicId,
userId: user.id,
subscriptionId,
}, },
}); });

View File

@@ -36,7 +36,7 @@ export default async function handler(
const event = stripe.webhooks.constructEvent( const event = stripe.webhooks.constructEvent(
rawBody, rawBody,
sig, sig,
process.env.STRIPE_WEBHOOK_SECRET_LEGACY!, process.env.STRIPE_WEBHOOK_SECRET!,
); );
const { db } = await createNextApiContext(req); const { db } = await createNextApiContext(req);
@@ -47,9 +47,9 @@ export default async function handler(
const metaData = checkoutSession.metadata; const metaData = checkoutSession.metadata;
if (metaData?.workspacePublicId) { if (metaData?.workspacePublicId && metaData.workspaceSlug) {
await workspaceRepo.update(db, metaData.workspacePublicId, { await workspaceRepo.update(db, metaData.workspacePublicId, {
...(metaData.workspaceSlug && { slug: metaData.workspaceSlug }), slug: metaData.workspaceSlug,
plan: "pro", plan: "pro",
}); });
} }

View File

@@ -42,7 +42,7 @@ const Card = ({
return ( return (
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"> <div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
<span>{title}</span> <span>{title}</span>
{labels.length || members.length || checklists.length > 0 ? ( {labels.length || members.length ? (
<div className="mt-2 flex flex-col justify-end"> <div className="mt-2 flex flex-col justify-end">
<div className="space-x-0.5"> <div className="space-x-0.5">
{labels.map((label) => ( {labels.map((label) => (

View File

@@ -375,7 +375,7 @@ export default function BoardPage() {
/> />
<Filters <Filters
labels={boardData?.labels ?? []} labels={boardData?.labels ?? []}
members={boardData?.workspace.members?.filter(member => member.user !== null) ?? []} members={boardData?.workspace.members ?? []}
position="left" position="left"
isLoading={!boardData} isLoading={!boardData}
/> />

View File

@@ -13,7 +13,7 @@ import { ImportBoardsForm } from "./components/ImportBoardsForm";
import { NewBoardForm } from "./components/NewBoardForm"; import { NewBoardForm } from "./components/NewBoardForm";
export default function BoardsPage() { export default function BoardsPage() {
const { openModal, modalContentType, isOpen } = useModal(); const { openModal, modalContentType } = useModal();
const { workspace, hasLoaded } = useWorkspace(); const { workspace, hasLoaded } = useWorkspace();
if (hasLoaded && !workspace.publicId) openModal("NEW_WORKSPACE"); if (hasLoaded && !workspace.publicId) openModal("NEW_WORKSPACE");
@@ -50,35 +50,12 @@ export default function BoardsPage() {
</div> </div>
</div> </div>
<> <Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
<Modal {modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
modalSize="md" {modalContentType === "NEW_BOARD" && <NewBoardForm />}
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"} {modalContentType === "IMPORT_BOARDS" && <ImportBoardsForm />}
> {modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
<FeedbackModal /> </Modal>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_BOARD"}
>
<NewBoardForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "IMPORT_BOARDS"}
>
<ImportBoardsForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
</>
<div className="flex h-full flex-row"> <div className="flex h-full flex-row">
<BoardsList /> <BoardsList />

View File

@@ -29,6 +29,7 @@ import LabelSelector from "./components/LabelSelector";
import ListSelector from "./components/ListSelector"; import ListSelector from "./components/ListSelector";
import MemberSelector from "./components/MemberSelector"; import MemberSelector from "./components/MemberSelector";
import { NewChecklistForm } from "./components/NewChecklistForm"; import { NewChecklistForm } from "./components/NewChecklistForm";
import NewChecklistItemForm from "./components/NewChecklistItemForm";
import NewCommentForm from "./components/NewCommentForm"; import NewCommentForm from "./components/NewCommentForm";
interface FormValues { interface FormValues {
@@ -142,7 +143,6 @@ export default function CardPage() {
openModal, openModal,
getModalState, getModalState,
clearModalState, clearModalState,
isOpen,
} = useModal(); } = useModal();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
@@ -213,179 +213,138 @@ export default function CardPage() {
<PageHead <PageHead
title={t`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`} title={t`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`}
/> />
<div className="flex h-full flex-1 flex-row overflow-hidden"> <div className="flex h-full flex-1 flex-row">
<div className="scrollbar-thumb-rounded-[4px] scrollbar-track-rounded-[4px] w-full flex-1 overflow-y-auto scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 hover:scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300 dark:hover:scrollbar-thumb-dark-300"> <div className="m-auto flex h-full w-full max-w-[800px] flex-col overflow-hidden">
<div className="p-auto mx-auto flex h-full w-full max-w-[800px] flex-col"> <div className="h-full max-h-[calc(100dvh-3rem)] overflow-y-auto p-6 md:max-h-[calc(100dvh-4rem)] md:p-8">
<div className="p-6 md:p-8"> <div className="mb-8 flex w-full items-center justify-between md:mt-6">
<div className="mb-8 flex w-full items-center justify-between md:mt-6"> {!card && isLoading && (
{!card && isLoading && ( <div className="flex space-x-2">
<div className="flex space-x-2"> <div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" /> <div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" /> </div>
</div> )}
)}
{card && (
<>
<Link
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
href={`/boards/${board?.publicId}`}
>
{board?.name}
</Link>
<IoChevronForwardSharp
size={18}
className="mx-2 text-light-900 dark:text-dark-900"
/>
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full space-y-6"
>
<div>
<input
type="text"
id="title"
{...register("title")}
onBlur={handleSubmit(onSubmit)}
className="block w-full border-0 bg-transparent p-0 py-0 font-bold tracking-tight text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
/>
</div>
</form>
<div className="flex">
<Dropdown />
</div>
</>
)}
{!card && !isLoading && (
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Card not found`}
</p>
)}
</div>
{card && ( {card && (
<> <>
<div className="mb-10 flex w-full max-w-2xl flex-col justify-between"> <Link
<form className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
onSubmit={handleSubmit(onSubmit)} href={`/boards/${board?.publicId}`}
className="w-full space-y-6" >
> {board?.name}
<div className="mt-2"> </Link>
<Editor <IoChevronForwardSharp
content={card.description} size={18}
onChange={(e) => setValue("description", e)} className="mx-2 text-light-900 dark:text-dark-900"
onBlur={() => handleSubmit(onSubmit)()}
workspaceMembers={board?.workspace.members ?? []}
/>
</div>
</form>
</div>
<Checklists
checklists={card.checklists}
cardPublicId={cardId}
activeChecklistForm={activeChecklistForm}
setActiveChecklistForm={setActiveChecklistForm}
/> />
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300"> <form
<h2 className="text-md pb-4 font-medium text-light-1000 dark:text-dark-1000"> onSubmit={handleSubmit(onSubmit)}
{t`Activity`} className="w-full space-y-6"
</h2> >
<div> <div>
<ActivityList <input
cardPublicId={cardId} type="text"
activities={activities ?? []} id="title"
isLoading={!card} {...register("title")}
isAdmin={workspace.role === "admin"} onBlur={handleSubmit(onSubmit)}
className="block w-full border-0 bg-transparent p-0 py-0 font-bold tracking-tight text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
/> />
</div> </div>
<div className="mt-6"> </form>
<NewCommentForm cardPublicId={cardId} /> <div className="flex">
</div> <Dropdown />
</div> </div>
</> </>
)} )}
{!card && !isLoading && (
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Card not found`}
</p>
)}
</div> </div>
{card && (
<>
<div className="mb-10 flex w-full max-w-2xl flex-col justify-between">
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full space-y-6"
>
<div className="mt-2">
<Editor
content={card.description}
onChange={(e) => setValue("description", e)}
onBlur={() => handleSubmit(onSubmit)()}
workspaceMembers={board?.workspace?.members ?? []}
/>
</div>
</form>
</div>
<Checklists
checklists={card.checklists}
cardPublicId={cardId}
activeChecklistForm={activeChecklistForm}
setActiveChecklistForm={setActiveChecklistForm}
/>
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
<h2 className="text-md pb-4 font-medium text-light-1000 dark:text-dark-1000">
{t`Activity`}
</h2>
<div>
<ActivityList
cardPublicId={cardId}
activities={activities ?? []}
isLoading={!card}
isAdmin={workspace.role === "admin"}
/>
</div>
<div className="mt-6">
<NewCommentForm cardPublicId={cardId} />
</div>
</div>
</>
)}
</div> </div>
</div> </div>
<> <Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
<Modal {modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
modalSize="md" {modalContentType === "NEW_LABEL" && (
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_LABEL"}
>
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchCard} /> <LabelForm boardPublicId={boardId ?? ""} refetch={refetchCard} />
</Modal> )}
{modalContentType === "EDIT_LABEL" && (
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_LABEL"}
>
<LabelForm <LabelForm
boardPublicId={boardId ?? ""} boardPublicId={boardId ?? ""}
refetch={refetchCard} refetch={refetchCard}
isEdit isEdit
/> />
</Modal> )}
{modalContentType === "DELETE_LABEL" && (
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_LABEL"}
>
<DeleteLabelConfirmation <DeleteLabelConfirmation
refetch={refetchCard} refetch={refetchCard}
labelPublicId={entityId} labelPublicId={entityId}
/> />
</Modal> )}
{modalContentType === "DELETE_CARD" && (
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_CARD"}
>
<DeleteCardConfirmation <DeleteCardConfirmation
boardPublicId={boardId ?? ""} boardPublicId={boardId ?? ""}
cardPublicId={cardId} cardPublicId={cardId}
/> />
</Modal> )}
{modalContentType === "DELETE_COMMENT" && (
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_COMMENT"}
>
<DeleteCommentConfirmation <DeleteCommentConfirmation
cardPublicId={cardId} cardPublicId={cardId}
commentPublicId={entityId} commentPublicId={entityId}
/> />
</Modal> )}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
<Modal {modalContentType === "ADD_CHECKLIST" && (
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "ADD_CHECKLIST"}
>
<NewChecklistForm cardPublicId={cardId} /> <NewChecklistForm cardPublicId={cardId} />
</Modal> )}
{modalContentType === "DELETE_CHECKLIST" && (
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_CHECKLIST"}
>
<DeleteChecklistConfirmation <DeleteChecklistConfirmation
cardPublicId={cardId} cardPublicId={cardId}
checklistPublicId={entityId} checklistPublicId={entityId}
/> />
</Modal> )}
</> </Modal>
</div> </div>
</> </>
); );

View File

@@ -1,26 +1,23 @@
import { useTheme } from "next-themes";
import { authClient } from "@kan/auth/client";
import PatternedBackground from "~/components/PatternedBackground"; import PatternedBackground from "~/components/PatternedBackground";
import { useTheme } from "~/providers/theme";
import Footer from "./Footer"; import Footer from "./Footer";
import Header from "./Header"; import Header from "./Header";
import { authClient } from "@kan/auth/client";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
const { theme } = useTheme(); const theme = useTheme();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const isLoggedIn = !!session?.user; const isLoggedIn = !!session?.user;
const isDarkMode = theme === "dark"; const isDarkMode = theme.activeTheme === "dark";
return ( return (
<> <>
<style jsx global>{` <style jsx global>{`
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
overflow: auto;
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#161616"}; background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#161616"};
} }
`}</style> `}</style>

View File

@@ -92,12 +92,12 @@ const Pricing = () => {
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`} {t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
</p> </p>
<div className="mt-12 flex flex-col items-center justify-center"> {/* Beta free pricing pill */}
<div className="mb-4 flex items-center gap-2 rounded-full border bg-white px-4 py-1.5 text-center text-xs font-bold text-gray-800 dark:border-dark-300 dark:bg-white dark:text-gray-800 lg:text-sm"> <div className="mt-4 flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800 dark:bg-green-700 dark:text-green-100">
<p> {t`All seats are free during the beta until Sept 1st!`}
{t`Launch offer: unlimited seats for just $29/month with Pro`} </div>
</p>
</div> <div className="mt-16 flex justify-center">
<fieldset aria-label={t`Payment frequency`}> <fieldset aria-label={t`Payment frequency`}>
<RadioGroup <RadioGroup
value={frequency} value={frequency}
@@ -108,12 +108,7 @@ const Pricing = () => {
<Radio <Radio
key={option.value} key={option.value}
value={option} value={option}
className={twMerge( className="cursor-pointer rounded-full px-2.5 py-1 text-xs text-light-900 data-[checked]:bg-dark-50 data-[checked]:text-white dark:data-[checked]:bg-light-50 dark:data-[checked]:text-dark-50 lg:text-sm"
"cursor-pointer rounded-full px-2.5 py-1 text-xs transition-colors lg:text-sm",
frequency?.value === option.value
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
: "text-light-900 hover:bg-light-100 dark:hover:bg-dark-100",
)}
> >
{option.label} {option.label}
</Radio> </Radio>
@@ -170,7 +165,7 @@ const Pricing = () => {
!tier.showPrice && "opacity-0", !tier.showPrice && "opacity-0",
)} )}
> >
{tier.price[frequency?.value ?? "monthly"]} {tier.price[frequency?.value || "monthly"]}
</span> </span>
{tier.showPriceSuffix && ( {tier.showPriceSuffix && (
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900"> <span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">

View File

@@ -6,7 +6,7 @@ import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
import Button from "~/components/Button"; import Button from "~/components/Button";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { useTheme } from "next-themes"; import { useTheme } from "~/providers/theme";
import Cta from "./components/Cta"; import Cta from "./components/Cta";
import FAQs from "./components/Faqs"; import FAQs from "./components/Faqs";
import Features from "./components/Features"; import Features from "./components/Features";
@@ -14,10 +14,9 @@ import Layout from "./components/Layout";
import Pricing from "./components/Pricing"; import Pricing from "./components/Pricing";
export default function HomeView() { export default function HomeView() {
const { resolvedTheme } = useTheme(); const theme = useTheme();
const isDarkMode = resolvedTheme === "dark";
const isDarkMode = theme.activeTheme === "dark";
return ( return (
<Layout> <Layout>
<PageHead title="Kan.bn | The open source alternative to Trello" /> <PageHead title="Kan.bn | The open source alternative to Trello" />
@@ -89,27 +88,19 @@ export default function HomeView() {
</div> </div>
<div className="px-4"> <div className="px-4">
<div className="mb-24 rounded-[16px] border border-light-300 bg-light-50 p-1 shadow-md dark:border-dark-300 dark:bg-dark-100 lg:rounded-[24px] lg:p-2"> <div className="mb-24 rounded-[16px] border border-light-300 bg-light-50 p-1 shadow-md dark:border-dark-300 dark:bg-dark-100 lg:rounded-[24px] lg:p-2">
<div className="relative overflow-hidden rounded-[12px] border border-light-300 shadow-sm dark:border-dark-300 lg:rounded-[16px]"> <div className="overflow-hidden rounded-[12px] border border-light-300 shadow-sm dark:border-dark-300 lg:rounded-[16px]">
<Image <Image
src={`/hero-light.png`} src={`/hero-${isDarkMode ? "dark" : "light"}.png`}
alt="kanban" alt="kanban"
width={1100} width={1100}
height={1000} height={1000}
className="block dark:hidden"
/>
<Image
src={`/hero-dark.png`}
alt="kanban"
width={1100}
height={1000}
className="hidden dark:block"
/> />
</div> </div>
</div> </div>
</div> </div>
<div className="relative pt-10"> <div className="relative pt-10">
<div id="features" className="absolute -top-20" /> <div id="features" className="absolute -top-20" />
<Features theme={resolvedTheme === "dark" ? "dark" : "light"} /> <Features theme={theme.activeTheme} />
</div> </div>
<div className="relative pt-10"> <div className="relative pt-10">
<div id="pricing" className="absolute -top-20" /> <div id="pricing" className="absolute -top-20" />
@@ -120,7 +111,7 @@ export default function HomeView() {
<FAQs /> <FAQs />
</div> </div>
<div className="relative"> <div className="relative">
<Cta theme={resolvedTheme ?? "light"} /> <Cta theme={theme.activeTheme} />
</div> </div>
</div> </div>
</Layout> </Layout>

View File

@@ -1,15 +1,11 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2"; import { HiXMark } from "react-icons/hi2";
import { z } from "zod"; import { z } from "zod";
import type { InviteMemberInput } from "@kan/api/types"; import type { InviteMemberInput } from "@kan/api/types";
import type { Subscription } from "@kan/shared/utils";
import { authClient } from "@kan/auth/client";
import { getSubscriptionByPlan } from "@kan/shared/utils";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Input from "~/components/Input"; import Input from "~/components/Input";
@@ -19,17 +15,7 @@ import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
export function InviteMemberForm({ export function InviteMemberForm() {
numberOfMembers,
subscriptions,
unlimitedSeats,
userId,
}: {
numberOfMembers: number;
subscriptions: Subscription[] | undefined;
unlimitedSeats: boolean;
userId: string | undefined;
}) {
const utils = api.useUtils(); const utils = api.useUtils();
const [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false); const [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false);
const { closeModal } = useModal(); const { closeModal } = useModal();
@@ -56,7 +42,7 @@ export function InviteMemberForm({
const refetchBoards = () => utils.board.all.refetch(); const refetchBoards = () => utils.board.all.refetch();
const inviteMember = api.member.invite.useMutation({ const createBoard = api.member.invite.useMutation({
onSuccess: async () => { onSuccess: async () => {
closeModal(); closeModal();
await utils.workspace.byId.refetch(); await utils.workspace.byId.refetch();
@@ -82,56 +68,8 @@ export function InviteMemberForm({
}, },
}); });
const teamSubscription = getSubscriptionByPlan(subscriptions, "team"); const onSubmit = (data: InviteMemberInput) => {
const proSubscription = getSubscriptionByPlan(subscriptions, "pro"); createBoard.mutate(data);
const hasTeamSubscription = !!teamSubscription;
const hasProSubscription = !!proSubscription;
let isYearly = false;
let price = t`$10/month`;
let billingType = t`monthly billing`;
if (teamSubscription?.periodStart && teamSubscription?.periodEnd) {
const periodStartDate = new Date(teamSubscription.periodStart);
const periodEndDate = new Date(teamSubscription.periodEnd);
const diffInDays = Math.round(
(periodEndDate.getTime() - periodStartDate.getTime()) /
(1000 * 60 * 60 * 24),
);
isYearly = diffInDays > 31;
price = isYearly ? t`$8/month` : t`$10/month`;
billingType = isYearly ? t`billed annually` : t`billed monthly`;
}
const onSubmit = (member: InviteMemberInput) => {
inviteMember.mutate(member);
};
const handleUpgrade = async () => {
const { data, error } = await authClient.subscription.upgrade({
plan: "team",
referenceId: workspace.publicId,
metadata: { userId },
seats: numberOfMembers,
successUrl: "/members",
cancelUrl: "/members",
returnUrl: "/members",
disableRedirect: true,
});
if (data?.url) {
window.location.href = data.url;
}
if (error) {
showPopup({
header: t`Error upgrading subscription`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
}
}; };
useEffect(() => { useEffect(() => {
@@ -159,11 +97,6 @@ export function InviteMemberForm({
<Input <Input
id="email" id="email"
placeholder={t`Email`} placeholder={t`Email`}
disabled={
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
}
{...register("email", { required: true })} {...register("email", { required: true })}
onKeyDown={async (e) => { onKeyDown={async (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
@@ -173,66 +106,22 @@ export function InviteMemberForm({
}} }}
errorMessage={errors.email?.message} errorMessage={errors.email?.message}
/> />
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
{hasTeamSubscription || hasProSubscription ? (
<div>
<span className="font-medium text-emerald-500 dark:text-emerald-400">
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
</span>
<p className="mt-1">
{unlimitedSeats
? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
</p>
</div>
) : (
<div>
<span className="font-medium text-light-950 dark:text-dark-950">
{t`Free Plan`}
</span>
<p className="mt-1">
{t`Inviting members requires a Team Plan. You'll be redirected to upgrade your workspace.`}
</p>
</div>
)}
</div>
)}
</div> </div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{(hasTeamSubscription || hasProSubscription) && <Toggle
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && ( label={t`Invite another`}
<Toggle isChecked={isCreateAnotherEnabled}
label={t`Invite another`} onChange={() => setIsCreateAnotherEnabled(!isCreateAnotherEnabled)}
isChecked={isCreateAnotherEnabled} />
onChange={() =>
setIsCreateAnotherEnabled(!isCreateAnotherEnabled)
}
/>
)}
<div> <div>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && <Button
!hasTeamSubscription && type="submit"
!hasProSubscription ? ( isLoading={createBoard.isPending}
<Button className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
type="button" >
onClick={handleUpgrade} {t`Invite member`}
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50" </Button>
>
{t`Upgrade to Team Plan`}
</Button>
) : (
<Button
type="submit"
disabled={inviteMember.isPending}
isLoading={inviteMember.isPending}
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
>
{t`Invite member`}
</Button>
)}
</div> </div>
</div> </div>
</form> </form>

View File

@@ -1,11 +1,8 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { HiEllipsisHorizontal, HiOutlinePlusSmall } from "react-icons/hi2"; import { HiEllipsisHorizontal, HiOutlinePlusSmall } from "react-icons/hi2";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import type { Subscription } from "@kan/shared/utils";
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth/client";
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
@@ -22,7 +19,7 @@ import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation"
import { InviteMemberForm } from "./components/InviteMemberForm"; import { InviteMemberForm } from "./components/InviteMemberForm";
export default function MembersPage() { export default function MembersPage() {
const { modalContentType, openModal, isOpen } = useModal(); const { modalContentType, openModal } = useModal();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const { data, isLoading } = api.workspace.byId.useQuery( const { data, isLoading } = api.workspace.byId.useQuery(
@@ -30,15 +27,6 @@ export default function MembersPage() {
// { enabled: workspace?.publicId ? true : false }, // { enabled: workspace?.publicId ? true : false },
); );
const { data: session } = authClient.useSession();
const subscriptions = data?.subscriptions as Subscription[] | undefined;
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
const TableRow = ({ const TableRow = ({
memberPublicId, memberPublicId,
memberId, memberId,
@@ -60,6 +48,7 @@ export default function MembersPage() {
isLastRow?: boolean; isLastRow?: boolean;
showSkeleton?: boolean; showSkeleton?: boolean;
}) => { }) => {
const { data: session } = authClient.useSession();
return ( return (
<tr className="rounded-b-lg"> <tr className="rounded-b-lg">
<td <td
@@ -171,28 +160,7 @@ export default function MembersPage() {
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]"> <h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Members`} {t`Members`}
</h1> </h1>
<div className="flex items-center gap-3"> <div className="flex">
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
<div
className={twMerge(
"flex items-center rounded-full border px-3 py-1 text-center text-xs",
teamSubscription || proSubscription
? "border-emerald-300 bg-emerald-50 text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
: "border-light-300 bg-light-50 text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900",
)}
>
<span className="font-medium">
{proSubscription
? t`Pro Plan`
: teamSubscription
? t`Team Plan`
: t`Free Plan`}
{proSubscription && unlimitedSeats && (
<span className="ml-1 text-xs"></span>
)}
</span>
</div>
)}
<Button <Button
onClick={() => openModal("INVITE_MEMBER")} onClick={() => openModal("INVITE_MEMBER")}
iconLeft={<HiOutlinePlusSmall className="h-4 w-4" />} iconLeft={<HiOutlinePlusSmall className="h-4 w-4" />}
@@ -254,40 +222,12 @@ export default function MembersPage() {
</div> </div>
</div> </div>
<> <Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
<Modal {modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
modalSize="md" {modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"} {modalContentType === "INVITE_MEMBER" && <InviteMemberForm />}
> {modalContentType === "REMOVE_MEMBER" && <DeleteMemberConfirmation />}
<FeedbackModal /> </Modal>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
>
<InviteMemberForm
userId={session?.user.id}
numberOfMembers={data?.members.length ?? 1}
subscriptions={subscriptions}
unlimitedSeats={unlimitedSeats}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "REMOVE_MEMBER"}
>
<DeleteMemberConfirmation />
</Modal>
</>
</div> </div>
</> </>
); );

View File

@@ -69,20 +69,17 @@ export function CardModal({
onClick={async (e) => { onClick={async (e) => {
e.preventDefault(); e.preventDefault();
closeModal(); closeModal();
try {
setTimeout(async () => { await router.replace(
try { `/${workspaceSlug}/${boardSlug}`,
await router.replace( undefined,
`/${workspaceSlug}/${boardSlug}`, {
undefined, shallow: true,
{ },
shallow: true, );
}, } catch (error) {
); console.error(error);
} catch (error) { }
console.error(error);
}
}, 400);
}} }}
> >
<HiXMark <HiXMark

View File

@@ -2,7 +2,6 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { keepPreviousData } from "@tanstack/react-query"; import { keepPreviousData } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { HiLink, HiOutlineLockClosed } from "react-icons/hi2"; import { HiLink, HiOutlineLockClosed } from "react-icons/hi2";
@@ -20,10 +19,6 @@ import Card from "~/views/board/components/Card";
import Filters from "~/views/board/components/Filters"; import Filters from "~/views/board/components/Filters";
import { CardModal } from "./CardModal"; import { CardModal } from "./CardModal";
const IS_CLOUD = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
const HIDE_POWERED_BY =
env("NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY") === "true";
export default function PublicBoardView() { export default function PublicBoardView() {
const router = useRouter(); const router = useRouter();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
@@ -45,7 +40,7 @@ export default function PublicBoardView() {
members: formatToArray(router.query.members), members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels), labels: formatToArray(router.query.labels),
}, },
{ enabled: router.isReady && !!boardSlug, placeholderData: keepPreviousData }, { enabled: !!boardSlug, placeholderData: keepPreviousData },
); );
const CopyBoardLink = () => { const CopyBoardLink = () => {
@@ -107,7 +102,7 @@ export default function PublicBoardView() {
<div className="relative h-full overflow-hidden rounded-md border pb-8 dark:border-dark-200"> <div className="relative h-full overflow-hidden rounded-md border pb-8 dark:border-dark-200">
<PatternedBackground /> <PatternedBackground />
<div className="z-10 flex w-full justify-between p-8"> <div className="z-10 flex w-full justify-between p-8">
{isLoading || !router.isReady ? ( {isLoading ? (
<div className="flex space-x-2"> <div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" /> <div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
</div> </div>
@@ -134,13 +129,13 @@ export default function PublicBoardView() {
</div> </div>
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300"> <div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
{isLoading || !router.isReady ? ( {isLoading ? (
<div className="ml-[2rem] flex"> <div className="ml-[2rem] flex">
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" /> <div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" /> <div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" /> <div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
</div> </div>
) : !data && !isLoading && router.isReady && !!boardSlug ? ( ) : !data ? (
<div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]"> <div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<HiOutlineLockClosed className="h-10 w-10 text-light-800 dark:text-dark-800" /> <HiOutlineLockClosed className="h-10 w-10 text-light-800 dark:text-dark-800" />
@@ -199,26 +194,12 @@ export default function PublicBoardView() {
<CopyBoardLink /> <CopyBoardLink />
</div> </div>
{IS_CLOUD && ( <Link
<Link className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000"
className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000" href="/"
href="/" >
> kan.bn
kan.bn </Link>
</Link>
)}
{!IS_CLOUD && !HIDE_POWERED_BY && (
<a
href="https://kan.bn"
target="_blank"
rel="noreferrer noopener"
className="absolute right-[1rem] inline-flex items-center gap-[0.175rem] rounded-full border border-light-300 bg-light-50 px-3 py-1 text-[11px] font-medium text-light-950 shadow-sm transition-colors hover:bg-light-100 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100"
>
<span>{`Powered by`}</span>
<span className="font-semibold">kan.bn</span>
</a>
)}
</div> </div>
</div> </div>
<Popup /> <Popup />

View File

@@ -1,184 +0,0 @@
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useMutation } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const FormSchema = z
.object({
currentPassword: z.string().min(1, t`Current password is required`),
newPassword: z
.string()
.min(8, t`Password must be at least 8 characters`)
.min(1, t`New password is required`),
confirmPassword: z.string().min(1, t`Please confirm your new password`),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: t`Passwords do not match`,
path: ["confirmPassword"],
})
.refine((data) => data.currentPassword !== data.newPassword, {
message: t`New password must be different from current password`,
path: ["newPassword"],
});
type FormValues = z.infer<typeof FormSchema>;
export function ChangePasswordFormConfirmation() {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const router = useRouter();
const utils = api.useUtils();
const {
register,
handleSubmit,
formState: { errors, isValid },
reset,
setError,
} = useForm<FormValues>({
resolver: zodResolver(FormSchema),
mode: "onChange",
});
const changePasswordMutation = useMutation({
mutationFn: async (data: FormValues) => {
const response = await authClient.changePassword({
newPassword: data.newPassword,
currentPassword: data.currentPassword,
revokeOtherSessions: true,
});
if (response?.error) {
throw new Error(response?.error.message || "Invalid Password");
}
},
onSuccess: async () => {
closeModal();
showPopup({
header: t`Password Changed`,
message: t`Your password has been changed.`,
icon: "success",
});
utils.invalidate();
reset();
router.push("/");
},
onError: async (error: any) => {
const errorMessage = error.message.toLowerCase();
if (errorMessage.includes("invalid password")) {
setError("currentPassword", {
type: "manual",
message: t`The current password you entered is incorrect.`,
});
} else {
closeModal();
showPopup({
header: t`Error Changing Password`,
message: t`An unexpected error occurred. Please try again later.`,
icon: "error",
});
}
},
});
const onSubmit = (data: FormValues) => {
changePasswordMutation.mutate(data);
};
const handleCancel = () => {
reset();
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 dark:text-white">{t`Change Password`}</h2>
<p className="mb-4 text-sm text-light-900">
{t`Enter your current password and choose a new secure password.`}
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-2">
<div>
<Input
id="currentPassword"
type="password"
{...register("currentPassword")}
placeholder={t`Enter your current password`}
/>
{errors.currentPassword && (
<p className="mt-2 text-xs text-red-400">
{errors.currentPassword.message}
</p>
)}
</div>
<div>
<Input
id="newPassword"
type="password"
{...register("newPassword")}
placeholder={t`Enter your new password`}
/>
{errors.newPassword && (
<p className="mt-2 text-xs text-red-400">
{errors.newPassword.message}
</p>
)}
</div>
<div>
<Input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
placeholder={t`Confirm your new password`}
/>
{errors.confirmPassword && (
<p className="mt-2 text-xs text-red-400">
{errors.confirmPassword.message}
</p>
)}
</div>
</div>
<div className="mt-[1.5rem] flex items-center gap-4">
<Button
variant="secondary"
onClick={handleCancel}
type="button"
fullWidth
size="lg"
>
{t`Cancel`}
</Button>
<Button
variant="primary"
type="submit"
disabled={!isValid || changePasswordMutation.isPending}
isLoading={changePasswordMutation.isPending}
fullWidth
size="lg"
>
{t`Change Password`}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
export function CustomURLConfirmation({
workspacePublicId,
}: {
workspacePublicId: string;
}) {
const { closeModal, entityId } = useModal();
const handleUpgrade = async () => {
try {
const response = await fetch("/api/stripe/create_checkout_session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
slug: entityId,
workspacePublicId: workspacePublicId,
cancelUrl: "/settings",
successUrl: "/settings",
}),
});
const { url } = (await response.json()) as { url: string };
if (url) {
window.location.href = url;
}
} catch (error) {
console.error("Error creating checkout session:", error);
}
};
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`Confirm URL change`}
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{t`Custom URLs are a premium feature. You'll be directed to upgrade your account.`}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button onClick={() => closeModal()} variant="secondary">
{t`Cancel`}
</Button>
<Button onClick={handleUpgrade}>{t`Upgrade`}</Button>
</div>
</div>
);
}

View File

@@ -98,7 +98,7 @@ const UpdateWorkspaceUrlForm = ({
if (!isWorkspaceSlugAvailable?.isAvailable) return; if (!isWorkspaceSlugAvailable?.isAvailable) return;
if (workspacePlan !== "pro" && env("NEXT_PUBLIC_KAN_ENV") === "cloud") if (workspacePlan !== "pro" && env("NEXT_PUBLIC_KAN_ENV") === "cloud")
return openModal("UPGRADE_TO_PRO", data.slug); return openModal("UPDATE_WORKSPACE_URL", data.slug);
updateWorkspaceSlug.mutate({ updateWorkspaceSlug.mutate({
workspacePublicId, workspacePublicId,

View File

@@ -1,103 +0,0 @@
import { t } from "@lingui/core/macro";
import { HiBolt, HiCheckBadge } from "react-icons/hi2";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
export function UpgradeToProConfirmation({
workspacePublicId,
}: {
userId: string;
workspacePublicId: string;
}) {
const { closeModal, entityId } = useModal();
const { showPopup } = usePopup();
const handleUpgrade = async () => {
try {
const response = await fetch("/api/stripe/create_checkout_session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...(entityId && { slug: entityId }),
workspacePublicId: workspacePublicId,
cancelUrl: "/settings",
successUrl: "/settings",
}),
});
const { url } = (await response.json()) as { url: string };
if (url) {
window.location.href = url;
}
} catch (error) {
console.error("Error creating checkout session:", error);
showPopup({
header: t`Error upgrading subscription`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
}
};
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-bold text-neutral-900 dark:text-dark-1000">
{t`Upgrade to Pro`}
</h2>
<p className="mb-4 text-sm font-medium text-light-900 dark:text-dark-900">
{t`Supercharge your workspace for just $29/month. Here's what you'll get:`}
</p>
<div className="rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
<div className="space-y-3">
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-5 w-5 flex-shrink-0 text-light-1000 dark:text-dark-950" />
<div className="flex items-center space-x-2">
<span className="text-sm text-neutral-900 dark:text-dark-1000">
{t`Unlimited members`}
</span>
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400 sm:text-[10px]">
{t`Launch offer`}
</span>
</div>
</div>
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-5 w-5 flex-shrink-0 text-light-1000 dark:text-dark-950" />
<span className="text-sm text-neutral-900 dark:text-dark-1000">
{t`Custom workspace URL`}
</span>
</div>
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-5 w-5 flex-shrink-0 text-light-1000 dark:text-dark-950" />
<span className="text-sm text-neutral-900 dark:text-dark-1000">
{t`Board analytics (coming soon)`}
</span>
</div>
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-5 w-5 flex-shrink-0 text-light-1000 dark:text-dark-950" />
<span className="text-sm text-neutral-900 dark:text-dark-1000">
{t`Priority email support`}
</span>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button onClick={() => closeModal()} variant="secondary">
{t`Cancel`}
</Button>
<Button
onClick={handleUpgrade}
iconRight={<HiBolt />}
>{t`Upgrade`}</Button>
</div>
</div>
);
}

View File

@@ -2,10 +2,7 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { HiBolt, HiMiniArrowTopRightOnSquare } from "react-icons/hi2"; import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import type { Subscription } from "@kan/shared/utils";
import { hasActiveSubscription } from "@kan/shared/utils";
import Button from "~/components/Button"; import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
@@ -18,36 +15,26 @@ import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import Avatar from "./components/Avatar"; import Avatar from "./components/Avatar";
import { ChangePasswordFormConfirmation } from "./components/ChangePasswordConfirmation";
import CreateAPIKeyForm from "./components/CreateAPIKeyForm"; import CreateAPIKeyForm from "./components/CreateAPIKeyForm";
import { CustomURLConfirmation } from "./components/CustomURLConfirmation";
import { DeleteAccountConfirmation } from "./components/DeleteAccountConfirmation"; import { DeleteAccountConfirmation } from "./components/DeleteAccountConfirmation";
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation"; import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm"; import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm"; import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm"; import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm"; import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
export default function SettingsPage() { export default function SettingsPage() {
const { modalContentType, openModal, isOpen } = useModal(); const { modalContentType, openModal } = useModal();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const utils = api.useUtils(); const utils = api.useUtils();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const router = useRouter(); const router = useRouter();
const workspaceUrlSectionRef = useRef<HTMLDivElement>(null); const workspaceUrlSectionRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
const { data } = api.user.getUser.useQuery(); const { data } = api.user.getUser.useQuery();
const { data: workspaceData } = api.workspace.byId.useQuery({
workspacePublicId: workspace.publicId,
});
const subscriptions = workspaceData?.subscriptions as
| Subscription[]
| undefined;
const { const {
data: integrations, data: integrations,
refetch: refetchIntegrations, refetch: refetchIntegrations,
@@ -95,17 +82,6 @@ export default function SettingsPage() {
} }
}, [router.query.edit]); }, [router.query.edit]);
// Open upgrade modal if upgrade=pro is in URL params
useEffect(() => {
if (
router.query.upgrade === "pro" &&
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasActiveSubscription(subscriptions, "pro")
) {
openModal("UPGRADE_TO_PRO");
}
}, [router.query.upgrade, subscriptions, openModal]);
const { mutateAsync: disconnectTrello } = const { mutateAsync: disconnectTrello } =
api.integration.disconnect.useMutation({ api.integration.disconnect.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -202,18 +178,6 @@ export default function SettingsPage() {
workspacePublicId={workspace.publicId} workspacePublicId={workspace.publicId}
workspaceDescription={workspace.description ?? ""} workspaceDescription={workspace.description ?? ""}
/> />
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasActiveSubscription(subscriptions, "pro") && (
<div className="mt-8">
<Button
onClick={() => openModal("UPGRADE_TO_PRO")}
iconRight={<HiBolt />}
>
{t`Upgrade to Pro`}
</Button>
</div>
)}
</div> </div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300"> <div className="mb-8 border-t border-light-300 dark:border-dark-300">
@@ -319,25 +283,6 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{isCredentialsEnabled && (
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Change Password`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`You are about to change your password.`}
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("CHANGE_PASSWORD")}
>
{t`Change Password`}
</Button>
</div>
</div>
)}
<div className="mb-8 border-t border-light-300 dark:border-dark-300"> <div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000"> <h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Delete account`} {t`Delete account`}
@@ -356,52 +301,19 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
<> <Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
<Modal {modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
modalSize="md" {modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"} {modalContentType === "DELETE_WORKSPACE" && (
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_WORKSPACE"}
>
<DeleteWorkspaceConfirmation /> <DeleteWorkspaceConfirmation />
</Modal> )}
{modalContentType === "UPDATE_WORKSPACE_URL" && (
<Modal <CustomURLConfirmation workspacePublicId={workspace.publicId} />
modalSize="sm" )}
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"} {modalContentType === "DELETE_ACCOUNT" && (
>
<UpgradeToProConfirmation
userId={data?.id ?? ""}
workspacePublicId={workspace.publicId}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_ACCOUNT"}
>
<DeleteAccountConfirmation /> <DeleteAccountConfirmation />
</Modal> )}
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
>
<ChangePasswordFormConfirmation />
</Modal>
</>
</div> </div>
</div> </div>
</> </>

View File

@@ -4,7 +4,6 @@ import { fontFamily } from "tailwindcss/defaultTheme";
import baseConfig from "@kan/tailwind-config/web"; import baseConfig from "@kan/tailwind-config/web";
export default { export default {
darkMode: "class",
content: [...baseConfig.content], content: [...baseConfig.content],
plugins: [require("@tailwindcss/typography")], plugins: [require("@tailwindcss/typography")],
presets: [baseConfig], presets: [baseConfig],

View File

@@ -22,11 +22,6 @@ services:
# Stripe # Stripe
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- STRIPE_WEBHOOK_SECRET_LEGACY=${STRIPE_WEBHOOK_SECRET_LEGACY}
- STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID=${STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID}
- STRIPE_TEAM_PLAN_YEARLY_PRICE_ID=${STRIPE_TEAM_PLAN_YEARLY_PRICE_ID}
- STRIPE_PRO_PLAN_MONTHLY_PRICE_ID=${STRIPE_PRO_PLAN_MONTHLY_PRICE_ID}
- STRIPE_PRO_PLAN_YEARLY_PRICE_ID=${STRIPE_PRO_PLAN_YEARLY_PRICE_ID}
# Email # Email
- SMTP_HOST=${SMTP_HOST} - SMTP_HOST=${SMTP_HOST}

View File

@@ -35,9 +35,6 @@ services:
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME} - NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN} - NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
# White label
- NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY=${NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY}
# Auth config (optional) # Auth config (optional)
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS} - NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP} - NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
@@ -46,11 +43,6 @@ services:
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY} - TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET} - TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
# Generic OIDC provider (optional)
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
- OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL}
# OAuth providers (optional) # OAuth providers (optional)
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS} - BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}

View File

@@ -31,11 +31,5 @@
"turbo": "^2.3.1", "turbo": "^2.3.1",
"typescript": "catalog:" "typescript": "catalog:"
}, },
"prettier": "@kan/prettier-config", "prettier": "@kan/prettier-config"
"pnpm": {
"overrides": {
"@types/minimatch": "npm:minimatch@*",
"@types/glob": "npm:@types/glob@^8.1.0"
}
}
} }

View File

@@ -7,10 +7,7 @@ import { createTRPCRouter, protectedProcedure } from "../trpc";
export const feedbackRouter = createTRPCRouter({ export const feedbackRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.meta({ .meta({ enabled: false, openApi: false })
enabled: false,
openapi: { enabled: false, method: "POST", path: "/feedback" },
})
.input( .input(
z.object({ z.object({
feedback: z.string().min(1), feedback: z.string().min(1),

View File

@@ -2,11 +2,8 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import * as memberRepo from "@kan/db/repository/member.repo"; import * as memberRepo from "@kan/db/repository/member.repo";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
import { updateSubscriptionSeats } from "@kan/stripe";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth"; import { assertUserInWorkspace } from "../utils/auth";
@@ -63,47 +60,6 @@ export const memberRouter = createTRPCRouter({
}); });
} }
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
const subscriptions = await subscriptionRepo.getByReferenceId(
ctx.db,
workspace.publicId,
);
// get the active subscriptions
const activeTeamSubscription = getSubscriptionByPlan(
subscriptions,
"team",
);
const activeProSubscription = getSubscriptionByPlan(
subscriptions,
"pro",
);
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
if (!activeTeamSubscription && !activeProSubscription) {
throw new TRPCError({
message: `Workspace with public ID ${workspace.publicId} does not have an active subscription`,
code: "NOT_FOUND",
});
}
// Update the Stripe subscription
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
try {
await updateSubscriptionSeats(
activeTeamSubscription.stripeSubscriptionId,
1,
);
} catch (error) {
console.error("Failed to update Stripe subscription seats:", error);
throw new TRPCError({
message: `Failed to update subscription for the new member.`,
code: "INTERNAL_SERVER_ERROR",
});
}
}
}
const existingUser = await userRepo.getByEmail(ctx.db, input.email); const existingUser = await userRepo.getByEmail(ctx.db, input.email);
const invite = await memberRepo.create(ctx.db, { const invite = await memberRepo.create(ctx.db, {
@@ -209,36 +165,6 @@ export const memberRouter = createTRPCRouter({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
}); });
// Handle subscription seat decrement for cloud environment
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
const subscriptions = await subscriptionRepo.getByReferenceId(
ctx.db,
workspace.publicId,
);
// get the active subscriptions
const activeTeamSubscription = getSubscriptionByPlan(
subscriptions,
"team",
);
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
// Only decrease seats if there's an active subscription and stripeSubscriptionId
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
try {
await updateSubscriptionSeats(
activeTeamSubscription.stripeSubscriptionId,
-1,
);
} catch (error) {
console.error(
"Failed to decrease Stripe subscription seats:",
error,
);
}
}
}
return { success: true }; return { success: true };
}), }),
}); });

View File

@@ -32,11 +32,6 @@ const createAuthWithHeaders = (
headers, headers,
body: { email: input.email, callbackURL: input.callbackURL }, body: { email: input.email, callbackURL: input.callbackURL },
}), }),
listActiveSubscriptions: (input: { workspacePublicId: string }) =>
auth.api.listActiveSubscriptions({
headers,
query: { referenceId: input.workspacePublicId },
}),
}, },
}; };
}; };

View File

@@ -37,7 +37,6 @@
}, },
"prettier": "@kan/prettier-config", "prettier": "@kan/prettier-config",
"dependencies": { "dependencies": {
"@better-auth/stripe": "^1.3.7", "better-auth": "^1.2.8"
"better-auth": "^1.3.7"
} }
} }

View File

@@ -1,18 +1,15 @@
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { stripe } from "@better-auth/stripe";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api"; import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
import { apiKey, genericOAuth } from "better-auth/plugins"; import { apiKey } from "better-auth/plugins";
import { magicLink } from "better-auth/plugins/magic-link"; import { magicLink } from "better-auth/plugins/magic-link";
import { socialProviderList } from "better-auth/social-providers"; import { socialProviderList } from "better-auth/social-providers";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import type { dbClient } from "@kan/db/client"; import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo"; import * as memberRepo from "@kan/db/repository/member.repo";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import * as schema from "@kan/db/schema"; import * as schema from "@kan/db/schema";
import { cloudMailerClient, sendEmail } from "@kan/email"; import { cloudMailerClient, sendEmail } from "@kan/email";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
@@ -86,20 +83,8 @@ export const socialProvidersPlugin = () => ({
{ {
method: "GET", method: "GET",
}, },
async (ctx) => { async (ctx) =>
const providers = ctx.context.socialProviders.map((p) => ctx.json(ctx.context.socialProviders.map((p) => p.id.toLowerCase())),
p.id.toLowerCase(),
);
// Add OIDC provider if configured
if (
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_DISCOVERY_URL
) {
providers.push("oidc");
}
return ctx.json(providers);
},
), ),
}, },
}); });
@@ -115,13 +100,13 @@ async function downloadImage(url: string): Promise<Buffer> {
export const initAuth = (db: dbClient) => { export const initAuth = (db: dbClient) => {
return betterAuth({ return betterAuth({
secret: process.env.BETTER_AUTH_SECRET!, secret: process.env.BETTER_AUTH_SECRET!,
baseURL: env("NEXT_PUBLIC_BASE_URL"), baseURL: process.env.NEXT_PUBLIC_BASE_URL!,
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
? [ ? [
env("NEXT_PUBLIC_BASE_URL") ?? "", process.env.NEXT_PUBLIC_BASE_URL!,
...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","), ...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","),
] ]
: [env("NEXT_PUBLIC_BASE_URL") ?? ""], : [process.env.NEXT_PUBLIC_BASE_URL!],
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "pg", provider: "pg",
schema: { schema: {
@@ -156,76 +141,6 @@ export const initAuth = (db: dbClient) => {
}, },
plugins: [ plugins: [
socialProvidersPlugin(), socialProvidersPlugin(),
...(process.env.NEXT_PUBLIC_KAN_ENV === "cloud"
? [
stripe({
stripeClient: createStripeClient(),
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
createCustomerOnSignUp: true,
subscription: {
enabled: true,
plans: [
{
name: "team",
priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!,
annualDiscountPriceId:
process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID!,
},
{
name: "pro",
priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!,
annualDiscountPriceId:
process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!,
},
],
authorizeReference: async (data) => {
const workspace = await workspaceRepo.getByPublicId(
db,
data.referenceId,
);
if (!workspace) {
return Promise.resolve(false);
}
const isUserInWorkspace =
await workspaceRepo.isUserInWorkspace(
db,
data.user.id,
workspace.id,
);
return isUserInWorkspace;
},
getCheckoutSessionParams: () => {
return {
params: {
allow_promotion_codes: true,
},
};
},
onSubscriptionComplete: async ({
subscription,
stripeSubscription,
}) => {
// Set unlimited seats to true for pro plans
if (subscription.plan === "pro") {
await subscriptionRepo.updateByStripeSubscriptionId(
db,
stripeSubscription.id,
{
unlimitedSeats: true,
},
);
console.log(
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
);
}
},
},
}),
]
: []),
// @todo: hasing is disabled due to a bug in the api key plugin // @todo: hasing is disabled due to a bug in the api key plugin
apiKey({ disableKeyHashing: true }), apiKey({ disableKeyHashing: true }),
magicLink({ magicLink({
@@ -247,25 +162,6 @@ export const initAuth = (db: dbClient) => {
} }
}, },
}), }),
// Generic OIDC provider
...(process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_DISCOVERY_URL
? [
genericOAuth({
config: [
{
providerId: "oidc",
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
discoveryUrl: process.env.OIDC_DISCOVERY_URL,
scopes: ["openid", "email", "profile"],
pkce: true,
},
],
}),
]
: []),
], ],
databaseHooks: { databaseHooks: {
user: { user: {
@@ -343,17 +239,37 @@ export const initAuth = (db: dbClient) => {
}, },
hooks: { hooks: {
after: createAuthMiddleware(async (ctx) => { after: createAuthMiddleware(async (ctx) => {
if ( if (ctx.path.startsWith("/get-session")) {
const user = ctx.context.session?.user;
if (
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
user &&
!user.stripeCustomerId
) {
const stripe = createStripeClient();
const stripeCustomer = await stripe.customers.create({
email: user.email,
metadata: {
userId: user.id,
},
});
await userRepo.update(db, user.id, {
stripeCustomerId: stripeCustomer.id,
});
}
} else if (
ctx.path === "/magic-link/verify" && ctx.path === "/magic-link/verify" &&
(ctx.query?.callbackURL as string | undefined)?.includes( (ctx.query?.callbackURL as string | undefined)?.includes(
"type=invite", "type=invite",
) ) &&
ctx.query?.memberPublicId
) { ) {
const userId = ctx.context.newSession?.session.userId; const userId = ctx.context.newSession?.session.userId;
const callbackURL = ctx.query?.callbackURL as string | undefined; const memberPublicId = ctx.query.memberPublicId as string;
const memberPublicId = callbackURL?.split("memberPublicId=")[1];
if (userId && memberPublicId) { if (userId) {
const member = await memberRepo.getByPublicId(db, memberPublicId); const member = await memberRepo.getByPublicId(db, memberPublicId);
if (member?.id) { if (member?.id) {

View File

@@ -1,14 +1,8 @@
import type { BetterAuthClientPlugin } from "better-auth"; import { BetterAuthClientPlugin } from "better-auth";
import type { BetterFetchOption } from "better-auth/react"; import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins";
import { stripeClient } from "@better-auth/stripe/client"; import { BetterFetchOption, createAuthClient } from "better-auth/react";
import {
apiKeyClient,
genericOAuthClient,
magicLinkClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import type { socialProvidersPlugin } from "./auth"; import { socialProvidersPlugin } from "./auth";
const socialProvidersPluginClient = { const socialProvidersPluginClient = {
id: "social-providers-plugin", id: "social-providers-plugin",
@@ -27,13 +21,5 @@ const socialProvidersPluginClient = {
} satisfies BetterAuthClientPlugin; } satisfies BetterAuthClientPlugin;
export const authClient = createAuthClient({ export const authClient = createAuthClient({
plugins: [ plugins: [magicLinkClient(), apiKeyClient(), socialProvidersPluginClient],
stripeClient({
subscription: true,
}),
magicLinkClient(),
apiKeyClient(),
genericOAuthClient(),
socialProvidersPluginClient,
],
}); });

View File

@@ -1,23 +0,0 @@
CREATE TABLE IF NOT EXISTS "subscription" (
"id" bigserial PRIMARY KEY NOT NULL,
"plan" varchar(255) NOT NULL,
"referenceId" varchar(12) NOT NULL,
"stripeCustomerId" varchar(255),
"stripeSubscriptionId" varchar(255),
"status" varchar(255) NOT NULL,
"periodStart" timestamp,
"periodEnd" timestamp,
"cancelAtPeriodEnd" boolean,
"seats" integer,
"trialStart" timestamp,
"trialEnd" timestamp,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "subscription" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "subscription" ADD CONSTRAINT "subscription_referenceId_workspace_publicId_fk" FOREIGN KEY ("referenceId") REFERENCES "public"."workspace"("publicId") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -1 +0,0 @@
ALTER TABLE "subscription" ADD COLUMN "unlimitedSeats" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -64,20 +64,6 @@
"when": 1755094668761, "when": 1755094668761,
"tag": "20250813141748_AddChecklistActivityTypes", "tag": "20250813141748_AddChecklistActivityTypes",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1756803246096,
"tag": "20250902085406_AddSubscriptions",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1757271312974,
"tag": "20250907185512_AddUnlimitedSeatsToSubscription",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,12 +1,12 @@
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { PGlite } from "@electric-sql/pglite"; import { drizzle as drizzlePgLite } from "drizzle-orm/pglite";
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp"; import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
import { drizzle as drizzlePg } from "drizzle-orm/node-postgres"; import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
import { drizzle as drizzlePgLite } from "drizzle-orm/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
import { Pool } from "pg"; import { Pool } from "pg";
import * as schema from "./schema"; import * as schema from "./schema";
import { PGlite } from "@electric-sql/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
export type dbClient = NodePgDatabase<typeof schema> & { export type dbClient = NodePgDatabase<typeof schema> & {
$client: Pool; $client: Pool;
@@ -18,10 +18,7 @@ export const createDrizzleClient = (): dbClient => {
if (!connectionString) { if (!connectionString) {
console.log("POSTGRES_URL environment variable is not set, using PGLite"); console.log("POSTGRES_URL environment variable is not set, using PGLite");
const client = new PGlite({ const client = new PGlite({ dataDir: "./pgdata", extensions: { uuid_ossp }});
dataDir: "./pgdata",
extensions: { uuid_ossp },
});
const db = drizzlePgLite(client, { schema }); const db = drizzlePgLite(client, { schema });
migrate(db, { migrationsFolder: "../../packages/db/migrations" }); migrate(db, { migrationsFolder: "../../packages/db/migrations" });

View File

@@ -1,91 +0,0 @@
import { eq } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { subscription } from "@kan/db/schema";
export const updateById = async (
db: dbClient,
subscriptionId: number,
updates: {
unlimitedSeats?: boolean;
status?: string;
seats?: number | null;
periodStart?: Date | null;
periodEnd?: Date | null;
cancelAtPeriodEnd?: boolean | null;
},
) => {
const [result] = await db
.update(subscription)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(subscription.id, subscriptionId))
.returning({
id: subscription.id,
plan: subscription.plan,
status: subscription.status,
unlimitedSeats: subscription.unlimitedSeats,
});
return result;
};
export const updateByStripeSubscriptionId = async (
db: dbClient,
stripeSubscriptionId: string,
updates: {
unlimitedSeats?: boolean;
status?: string;
seats?: number | null;
periodStart?: Date | null;
periodEnd?: Date | null;
cancelAtPeriodEnd?: boolean | null;
},
) => {
const [result] = await db
.update(subscription)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(subscription.stripeSubscriptionId, stripeSubscriptionId))
.returning({
id: subscription.id,
plan: subscription.plan,
status: subscription.status,
unlimitedSeats: subscription.unlimitedSeats,
});
return result;
};
export const getByStripeSubscriptionId = async (
db: dbClient,
stripeSubscriptionId: string,
) => {
return await db.query.subscription.findFirst({
where: eq(subscription.stripeSubscriptionId, stripeSubscriptionId),
});
};
export const getByReferenceId = async (db: dbClient, referenceId: string) => {
return await db.query.subscription.findMany({
where: eq(subscription.referenceId, referenceId),
});
};
export const create = async (
db: dbClient,
data: {
plan: string;
referenceId: string;
userId: string;
stripeCustomerId: string;
status: string;
},
) => {
const [result] = await db.insert(subscription).values(data).returning();
return result;
};

View File

@@ -121,17 +121,6 @@ export const getByPublicIdWithMembers = (
}, },
}, },
}, },
subscriptions: {
columns: {
id: true,
plan: true,
status: true,
seats: true,
unlimitedSeats: true,
periodStart: true,
periodEnd: true,
},
},
}, },
where: and( where: and(
eq(workspaces.publicId, workspacePublicId), eq(workspaces.publicId, workspacePublicId),

View File

@@ -10,4 +10,3 @@ export * from "./lists";
export * from "./users"; export * from "./users";
export * from "./integrations"; export * from "./integrations";
export * from "./workspaces"; export * from "./workspaces";
export * from "./subscriptions";

View File

@@ -1,38 +0,0 @@
import { relations } from "drizzle-orm";
import {
bigserial,
boolean,
integer,
pgTable,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { workspaces } from "./workspaces";
export const subscription = pgTable("subscription", {
id: bigserial("id", { mode: "number" }).primaryKey(),
plan: varchar("plan", { length: 255 }).notNull(),
referenceId: varchar("referenceId", { length: 12 })
.notNull()
.references(() => workspaces.publicId),
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
status: varchar("status", { length: 255 }).notNull(),
periodStart: timestamp("periodStart"),
periodEnd: timestamp("periodEnd"),
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
seats: integer("seats"),
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
trialStart: timestamp("trialStart"),
trialEnd: timestamp("trialEnd"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
}).enableRLS();
export const subscriptionsRelations = relations(subscription, ({ one }) => ({
workspace: one(workspaces, {
fields: [subscription.referenceId],
references: [workspaces.publicId],
}),
}));

View File

@@ -11,7 +11,6 @@ import {
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { boards } from "./boards"; import { boards } from "./boards";
import { subscription } from "./subscriptions";
import { users } from "./users"; import { users } from "./users";
export const memberRoles = ["admin", "member", "guest"] as const; export const memberRoles = ["admin", "member", "guest"] as const;
@@ -61,7 +60,6 @@ export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
}), }),
members: many(workspaceMembers), members: many(workspaceMembers),
boards: many(boards), boards: many(boards),
subscriptions: many(subscription),
})); }));
export const workspaceMembers = pgTable("workspace_members", { export const workspaceMembers = pgTable("workspace_members", {

View File

@@ -45,14 +45,14 @@ export const sendEmail = async (
html, html,
}; };
// if (cloudMailerClient) { if (cloudMailerClient) {
// const response = await cloudMailerClient.emails.send({ const response = await cloudMailerClient.emails.send({
// ...options, ...options,
// body: html, body: html,
// }); });
// return response; return response;
// } }
const response = await transporter.sendMail(options); const response = await transporter.sendMail(options);

View File

@@ -1,3 +1,2 @@
export * from "./generateUID"; export * from "./generateUID";
export * from "./generateSlug"; export * from "./generateSlug";
export * from "./subscriptions";

View File

@@ -1,57 +0,0 @@
export type SubscriptionStatus =
| "active"
| "trialing"
| "past_due"
| "canceled"
| "unpaid";
export type SubscriptionPlan = "team" | "pro";
export interface Subscription {
id: number | null;
plan: string;
status: string;
seats: number | null;
unlimitedSeats: boolean;
periodStart: Date | null;
periodEnd: Date | null;
referenceId: string;
stripeSubscriptionId: string | null;
stripeCustomerId: string | null;
createdAt: Date;
updatedAt: Date;
}
export const getActiveSubscriptions = (
subscriptions: Subscription[] | undefined,
) => {
if (!subscriptions) return [];
return subscriptions.filter(
(sub) => sub.status === "active" || sub.status === "trialing",
);
};
export const getSubscriptionByPlan = (
subscriptions: Subscription[] | undefined,
plan: SubscriptionPlan,
) => {
if (!subscriptions) return undefined;
return subscriptions.find(
(sub) =>
sub.plan === plan &&
(sub.status === "active" || sub.status === "trialing"),
);
};
export const hasActiveSubscription = (
subscriptions: Subscription[] | undefined,
plan: SubscriptionPlan,
) => {
return getSubscriptionByPlan(subscriptions, plan) !== undefined;
};
export const hasUnlimitedSeats = (
subscriptions: Subscription[] | undefined,
) => {
const activeSubscriptions = getActiveSubscriptions(subscriptions);
return activeSubscriptions.some((sub) => sub.unlimitedSeats);
};

View File

@@ -10,65 +10,11 @@ const createStripeClient = () => {
} }
const stripe = new Stripe(stripeSecretKey, { const stripe = new Stripe(stripeSecretKey, {
apiVersion: "2025-08-27.basil", apiVersion: "2025-05-28.basil",
httpClient: Stripe.createFetchHttpClient(),
}); });
return stripe; return stripe;
}; };
export const updateSubscriptionSeats = async (
stripeSubscriptionId: string,
seatIncrement = 1,
): Promise<Stripe.Subscription> => {
const stripe = createStripeClient();
// First, retrieve the current subscription to get the subscription items
const subscription = await stripe.subscriptions.retrieve(
stripeSubscriptionId,
{
expand: ["items"],
},
);
if (!subscription.items.data.length) {
throw new Error(
`No subscription items found for subscription ${stripeSubscriptionId}`,
);
}
// Get the first subscription item
const subscriptionItem = subscription.items.data[0];
if (!subscriptionItem) {
throw new Error(
`No subscription item found for subscription ${stripeSubscriptionId}`,
);
}
const currentQuantity = subscriptionItem.quantity ?? 1;
const newQuantity = currentQuantity + seatIncrement;
// Ensure we don't go below 1 seat
if (newQuantity < 1) {
throw new Error(
`Cannot reduce seats below 1. Current: ${currentQuantity}, Requested change: ${seatIncrement}`,
);
}
// Update the subscription with the new quantity and immediate invoicing
const updatedSubscription = await stripe.subscriptions.update(
stripeSubscriptionId,
{
items: [
{
id: subscriptionItem.id,
quantity: newQuantity,
},
],
proration_behavior: "create_prorations",
},
);
return updatedSubscription;
};
export { createStripeClient }; export { createStripeClient };

6807
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -72,9 +72,6 @@
"VK_CLIENT_SECRET", "VK_CLIENT_SECRET",
"LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_ID",
"LINKEDIN_CLIENT_SECRET", "LINKEDIN_CLIENT_SECRET",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_DISCOVERY_URL",
"REDDIT_CLIENT_ID", "REDDIT_CLIENT_ID",
"REDDIT_CLIENT_SECRET", "REDDIT_CLIENT_SECRET",
"ROBLOX_CLIENT_ID", "ROBLOX_CLIENT_ID",
@@ -96,14 +93,9 @@
"SMTP_PASSWORD", "SMTP_PASSWORD",
"SMTP_SECURE", "SMTP_SECURE",
"NEXT_PUBLIC_KAN_ENV", "NEXT_PUBLIC_KAN_ENV",
"NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY",
"STRIPE_SECRET_KEY", "STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SECRET",
"STRIPE_WEBHOOK_SECRET_LEGACY", "STRIPE_PRO_PLAN_PRICE_ID",
"STRIPE_PRO_PLAN_MONTHLY_PRICE_ID",
"STRIPE_PRO_PLAN_YEARLY_PRICE_ID",
"STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID",
"STRIPE_TEAM_PLAN_YEARLY_PRICE_ID",
"NEXT_PUBLIC_STORAGE_DOMAIN", "NEXT_PUBLIC_STORAGE_DOMAIN",
"NEXT_PUBLIC_STORAGE_URL", "NEXT_PUBLIC_STORAGE_URL",
"NEXT_PUBLIC_AVATAR_BUCKET_NAME", "NEXT_PUBLIC_AVATAR_BUCKET_NAME",