Compare commits

...

67 Commits

Author SHA1 Message Date
Henry
6878c5af5a fix: reliably select workspace on login 2026-05-21 15:38:15 +01:00
hjball
edb88525c0 chore: compile translations 2026-05-18 22:21:55 +00:00
hjball
d69051611c chore: update translations 2026-05-18 22:21:52 +00:00
Henry
cf566e04c6 feat(cloud): improved upgrade journey 2026-05-18 23:20:11 +01:00
Henry
6ea622e7f7 fix(cloud): use prev_licence_key for upgrade/downgrade handler 2026-05-16 23:38:04 +01:00
Danny Halarewich
0e7b7a072c fix(editor): convert pasted markdown to formatted content (#498)
The tiptap-markdown extension was registered with default config, where
`transformPastedText` defaults to false. As a result, pasting markdown
text into card descriptions resulted in literal characters (e.g. '# heading'
shown as a paragraph of text) rather than rendered formatting.

Typing markdown shortcuts in the editor already worked because that goes
through a different path. Programmatic writes via the API also work
because they bypass the editor entirely. Only paste was affected.

Enabling `transformPastedText: true` makes pasted markdown behave as
users intuitively expect, matching the typed-markdown UX.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 23:03:06 +01:00
Nick Meinhold
f801d2a7eb fix(s3): default S3_REGION to us-east-1 instead of empty string (#495)
The AWS SDK throws "Region is missing" when S3Client is constructed
with an empty-string region, so any S3 feature (avatars, attachments,
presigned URL generation) is silently broken when S3_REGION is unset
— even though .env.example ships it unset and env.ts declares it
optional. The schema and the runtime disagreed about whether the var
was required.

Default to "us-east-1" in createS3Client. S3-compatible providers
(MinIO, Backblaze B2, R2, DigitalOcean Spaces, Wasabi) ignore the
region entirely; real AWS S3 users should set S3_REGION explicitly
to their bucket's actual region (clarified in .env.example).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:56:48 +01:00
Nick Meinhold
d9157b6218 fix(workspace): point package "types" exports at src/, not stale dist/ (#496)
Each workspace package had its exports map pointing `types` at
`./dist/...d.ts` while `default` pointed at `./src/...ts`. The dist
directory is a build artifact only refreshed when someone runs
`pnpm build` in that package — meaning every other package in the
monorepo typechecks against last week's type signatures.

Concrete failure mode: contributor edits a function in
packages/shared/src/utils/foo.ts (changes a parameter type, adds a
new export, etc.), runs `pnpm typecheck` from root, sees green. The
check actually validated against the stale dist/.d.ts. Real type
errors stay invisible until CI builds shared first, by which point
the diff is already pushed.

Fix: point `types` at the same `src/` paths the runtime resolves to.
TypeScript reads .ts source as types fine when consumers share the
same TS version, which a monorepo guarantees. Verified end-to-end:
adding a new export to shared and immediately typechecking @kan/api
now picks it up without rebuilding shared, and breaking a return
type immediately fails the consumer's typecheck.

Applied to @kan/api, @kan/db, @kan/logger, @kan/shared.
@kan/email is intentionally left as-is because its source is .tsx
(JSX) — pointing types at .tsx would force every consumer to enable
--jsx in their tsconfig, which is a worse cascade than the stale
dist problem we're solving.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:55:07 +01:00
hjball
711ff7f610 chore: update translations 2026-05-16 21:36:10 +00:00
Henry
742e8a1fab fix(cloud): prevent reprompting users to onboard after first signup 2026-05-16 22:34:59 +01:00
hjball
69ed5fd8f2 chore: compile translations 2026-05-16 21:25:37 +00:00
hjball
e21830138d chore: update translations 2026-05-16 21:25:33 +00:00
Henry
f38533590d feat(cloud): fix upgrade/downgrade and migrate webhook handler 2026-05-16 22:23:59 +01:00
hjball
7d52e4eb88 chore: compile translations 2026-05-15 21:34:55 +00:00
hjball
692123433b chore: update translations 2026-05-15 21:34:51 +00:00
Henry
8436644966 feat(cloud): improve partner onboarding 2026-05-15 22:33:18 +01:00
Henry
0c2b670baf Update README.md 2026-05-15 21:01:42 +01:00
Henry
584fe13244 fix(cloud): allow callback without code 2026-05-14 14:38:34 +01:00
Henry
cc6804c21d fix(cloud): update license key on conflict 2026-05-14 14:02:17 +01:00
Henry
8512946102 fix(cloud): partner webhook integration 2026-05-14 13:42:36 +01:00
Henry
6bebb14855 feat(cloud): add partner config to docker compose 2026-05-14 13:22:06 +01:00
hjball
0adb68d547 chore: update translations 2026-05-13 21:12:51 +00:00
Henry
e26c122d76 fix: add card number fields to zod schemas 2026-05-13 22:11:29 +01:00
hjball
95321c1c0d chore: update translations 2026-05-13 20:41:18 +00:00
Henry
f459c489f9 fix: broken migrations 2026-05-13 21:39:47 +01:00
hjball
460008d340 chore: compile translations 2026-05-12 21:40:38 +00:00
hjball
4371fe2d4d chore: update translations 2026-05-12 21:40:34 +00:00
Henry
60d8e1bd83 feat(cloud): partner integration (#493)
* feat: add partnerLicenseKey and partnerTier to subscription schema

* feat: add repo funcs

* feat: set up partner webhook handler
2026-05-12 22:39:07 +01:00
Henry
4459fbb4b2 fix: pin pnpm version in dockerfile to prevent corepack fetching latest 2026-05-11 15:49:34 +01:00
hjball
507c553778 chore: update translations 2026-05-11 12:45:01 +00:00
Henry
3d9e859b31 refactor: small tweaks to password reset flow 2026-05-11 13:43:38 +01:00
hjball
54dcd08d9a chore: compile translations 2026-05-11 12:42:35 +00:00
hjball
468aeef8b6 chore: update translations 2026-05-11 12:42:31 +00:00
Morfixx
71205b67dc fix: unable to change password when using magic link (#484)
* fix: fixed password not resetting & added password prompt with save guards

* fix: migrated to using setPassword
2026-05-11 13:40:59 +01:00
Midas
607672718c Add cardPublicId to card webhook payloads (#490)
* Add cardPublicId to card webhook payloads

* fix: include card publicId in webhook payloads

Add data.card.publicId to webhook payloads while keeping data.card.id for compatibility, and update card/webhook call sites and tests so webhook consumers can reliably use public IDs.
2026-05-11 13:22:21 +01:00
Henry
dd262ee50f fix: allow reordering without supplying an index when updating cards (#492) 2026-05-11 13:18:07 +01:00
Henry
82c8eef0c2 Update README.md 2026-05-05 21:50:50 +01:00
hjball
ea116268ff chore: update translations 2026-05-05 20:49:45 +00:00
Henry
74384d841e Update README.md 2026-05-05 21:48:29 +01:00
hjball
c0962e9f70 chore: compile translations 2026-04-28 21:31:18 +00:00
hjball
3e6aec2d6b chore: update translations 2026-04-28 21:31:14 +00:00
Florent Azonnoudo
4658b2961c feat: add ticket numbers to cards with workspace prefix (#443)
* feat: add ticket numbers to cards with workspace prefix

- Add cardNumber to cards
- Add cardPrefix/cardCounter to workspace
- Populate initial prefixes and numbers via new migration
- Introduce generateWorkspacePrefix util and export it
- Include cardNumber in card-related API responses and search results
- Render and display tickets as PREFIX-NUMBER in UI
- Update Card component to accept ticketNumber
- Update CardModal and board cards to show number when available
- Add cardNumber to CommandPalette search results type

* feat: regen migration

* feat: enhance card and workspace schemas with cardNumber and indexing

- Updated the card repository to allocate card numbers atomically per workspace.
- Modified the card schema to include a cardNumber field and added an index on listId and cardNumber for improved query performance.
- Enhanced the workspace schema to include an index on cardPrefix for optimized lookups.
- Adjusted the migration journal to reflect the new schema changes and their timestamps.
- Updated the regex in workspace repository to allow alphanumeric prefixes in ticket IDs.

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-04-28 22:29:41 +01:00
hjball
bc8b5463b3 chore: compile translations 2026-04-20 21:37:14 +00:00
hjball
9eee59a877 chore: update translations 2026-04-20 21:37:10 +00:00
Henry
e32e400976 chore: clean up types (#476) 2026-04-20 22:35:39 +01:00
Louie Chillingworth
b4b07f3120 fix: remove duplicate OIDC provider registration in getSocialProviders (#475)
The getSocialProviders endpoint was manually pushing "oidc" to the 
providers array when OIDC env vars are present, but Better Auth already 
includes the OIDC provider in ctx.context.socialProviders when it is 
configured. This resulted in ["oidc","oidc"] being returned from 
/api/auth/social-providers, causing a duplicate provider registration 
that led to a TypeError: Cannot read properties of null (reading 'id') 
during the OIDC login callback, preventing all OIDC logins.

Fix: remove the manual providers.push("oidc") block as it is redundant.
2026-04-20 21:20:23 +01:00
vvaswani
e39faa1172 fix: use stable public ids for card webhook (#463)
Signed-off-by: Vikram Vaswani <2571660+vvaswani@users.noreply.github.com>
2026-04-20 21:19:31 +01:00
hjball
b46979f539 chore: update translations 2026-04-18 20:42:20 +00:00
Nick Meinhold
f4237fffe6 fix: redirect to boards list after archiving/unarchiving a board (#459)
Previously, archiving or unarchiving a board left the user on the
(now archived/unarchived) board view with no clear navigation path.
This redirects to /boards after either action, matching the existing
behaviour of delete and move operations.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 21:41:04 +01:00
Redox
1d9bb96367 Don't default NEXT_PUBLIC_STORAGE_URL to empty string (#455) 2026-04-18 21:33:29 +01:00
hjball
bcb449e394 chore: update translations 2026-04-18 20:30:54 +00:00
Morfixx
ae4bc77407 feat: added schemas for openapi (#452) 2026-04-18 21:29:35 +01:00
hjball
6056167063 chore: update translations 2026-04-18 08:33:16 +00:00
Henry
adf91c7dfd refactor: remove legacy code from NewWorkspaceForm (#471) 2026-04-18 09:31:51 +01:00
hjball
bba9a94e45 chore: update translations 2026-04-17 21:23:27 +00:00
Henry
e36eb64c59 fix(cloud): enable upgrading new and existing workspaces onto team/pro (#470)
* feat(cloud): enable upgrading new and existing workspaces onto team/pro

* refactor: clean up

* fix: toggle spacing
2026-04-17 22:22:14 +01:00
hjball
201fe05493 chore: update translations 2026-04-11 07:19:15 +00:00
Henry
25490b2223 feat(cloud): temporarily disable context menu 2026-04-11 08:17:59 +01:00
hjball
b3108942db chore: compile translations 2026-04-11 07:01:09 +00:00
hjball
2ba759966e chore: update translations 2026-04-11 07:01:05 +00:00
Henry
7045cd8e25 feat(cloud): improved workspace onboarding (#462)
* feat: setup onboarding and select plan page

* feat: add workspace details step

* refactor: optimize board rendering in workspace details

* feat: tweak padding in boards mockup

* fix: validate/convert slug input

* refactor: tweak onboarding steps UX

* feat: add team to workspace plans

* feat: allow setting description when creating a workspace

* feat: add logging to next api routes

* feat: create worspace on checkout.session.completed

* chore: update types

* refactor: improve webhook logs

* feat: create workspace on successful activation

* feat: set slug on workspace name change

* feat: add returnUrl params
2026-04-11 07:59:26 +01:00
hjball
603cc46b7d chore: compile translations 2026-04-01 13:35:04 +00:00
hjball
e9d338384a chore: update translations 2026-04-01 13:34:59 +00:00
Nick Meinhold
8a2a9ecef5 fix: avatar upload display and oversized crop on HiDPI screens (#442)
Two related avatar bugs:

1. getAvatarUrl() returned empty string for S3 keys, so uploaded
   avatars never displayed. Now constructs the full URL using
   NEXT_PUBLIC_STORAGE_URL and NEXT_PUBLIC_AVATAR_BUCKET_NAME,
   with support for both path-style (MinIO) and virtual-hosted
   (Tigris/AWS S3) URLs.

2. Avatar crop scaled canvas by devicePixelRatio (4x pixels on
   Retina), producing blobs that exceeded the 2MB upload limit
   even for small source images. Now caps output at 512x512 and
   uses quality=0.85 for toBlob().

Closes #440, closes #441

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-01 14:33:11 +01:00
Charity
3a44957662 feat: add card context menu and duplication functionality (#381)
* feat: add card context menu and duplication functionality

* Implemented a context menu for cards allowing actions such as copying links, duplicating cards, and managing members, labels, and due dates
* Added modals for card duplication and context actions.
* Updated API with a new endpoint for duplicating cards, including options for copying labels, members, and checklists

* refactor: remove cardPublicId from context menu and related components

* Removed cardPublicId prop from CardContextMenu and CardContextMembersModal for cleaner context handling
* Updated scrollbar styling

* feat: add delete card functionality to context menu
2026-04-01 14:32:56 +01:00
Henry
20e6c99e32 feat: enhance logging with request input (#450) 2026-03-23 22:31:02 +00:00
Henry
c0aae5449f fix: prevent invalid calls to fetch label by publicId (#449)
* fix: prevent invalid calls to fetch label by publicId

* fix: ensure query only runs when isEdit is true
2026-03-23 21:40:35 +00:00
137 changed files with 26276 additions and 3464 deletions

View File

@@ -23,8 +23,8 @@ SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
NEXT_PUBLIC_DISABLE_EMAIL=
# S3 storage (optional)
S3_REGION=
S3_ENDPOINT=
S3_REGION= # required for AWS S3 (e.g. us-east-1); ignored by most S3-compatible providers — defaults to us-east-1 if unset
S3_ENDPOINT= # set for S3-compatible providers (MinIO, R2, Spaces, etc.); leave blank for AWS S3
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=

View File

@@ -227,6 +227,12 @@ We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
</a>
## Sponsors ❤️
[<img height="100" alt="image" src="https://github.com/user-attachments/assets/e331c71f-ac86-46a6-bceb-ce276de094b0" />](https://www.testmuai.com)
Proudly sponsored by [TestMu AI (formerly LambdaTest)](https://www.testmuai.com) - an AI-native testing cloud platform built for modern engineering teams. Covering everything from autonomous test creation and fast execution to testing AI agents like chatbots and voice assistants. If you're serious about testing, go check them out.
## License 📝
Kan is licensed under the [AGPLv3 license](LICENSE).

View File

@@ -11,6 +11,7 @@ RUN apk update && \
apk add --no-cache libc6-compat && \
rm -rf /var/cache/apk/* /tmp/* || true && \
corepack enable && \
corepack prepare pnpm@9.14.2 --activate && \
npm install turbo@2.3.1 --global && \
pnpm config set store-dir ~/.pnpm-store

View File

@@ -88,6 +88,12 @@ checksums:
7L01XJ/message: c46571856723b03262fd33f511116298
7L01XJ/origin/0/0: feacdaf2791aaddec128f463ffa2e807
7L01XJ/translation: c46571856723b03262fd33f511116298
JEt3q7/message: 233710e683076c7eb6400a5c5ddd1d2a
JEt3q7/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
JEt3q7/translation: 233710e683076c7eb6400a5c5ddd1d2a
mkehy7/message: 71c4ebc206483ae49a2bd3dd7b6038ba
mkehy7/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
mkehy7/translation: 71c4ebc206483ae49a2bd3dd7b6038ba
F6pfE9/message: 3e1ec025c4a50830bbb9ad57a176630a
F6pfE9/origin/0/0: 4b563046ba98abb3777fb1c3560866aa
F6pfE9/origin/1/0: f64cd62b620f454998523af2a4330278
@@ -103,6 +109,9 @@ checksums:
tMYIu7/message: 8b1f0bb96a905646ecfad1cfdfa42168
tMYIu7/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
tMYIu7/translation: 8b1f0bb96a905646ecfad1cfdfa42168
UGCIzB/message: f7b6699c16726afc48f1e1d1339b2e7e
UGCIzB/origin/0/0: 0eeeb7e68164ff118040918f1ef00a7a
UGCIzB/translation: f7b6699c16726afc48f1e1d1339b2e7e
B3xxao/message: 9d80becc4fe65a74e0004e4fd290735d
B3xxao/origin/0/0: 6812b79a8fdc3527586b523d4c137340
B3xxao/translation: 9d80becc4fe65a74e0004e4fd290735d
@@ -221,7 +230,8 @@ checksums:
Z3krJD/origin/0/0: e9da4b3a0eedd68ed126437194a482fe
Z3krJD/translation: 0077436d9f37bfd64f3ae076a5a05040
WnEwDO/message: 2959fd276248208b65cb27ed46b20135
WnEwDO/origin/0/0: d6f5120f072a821219608624f361384e
WnEwDO/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
WnEwDO/origin/1/0: d6f5120f072a821219608624f361384e
WnEwDO/translation: 2959fd276248208b65cb27ed46b20135
j1%2BHPc/message: baf4c3cd580291f41458f9ed28102c49
j1%2BHPc/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
@@ -235,6 +245,9 @@ checksums:
ZXSPJt/message: 1b5749b0cca6a62d75a577bee6804074
ZXSPJt/origin/0/0: 0627e0040ca4939c9a57349e98c51797
ZXSPJt/translation: 1b5749b0cca6a62d75a577bee6804074
YkfDoz/message: 5d4f1c56f4e9e579ec9967ca68cc64ac
YkfDoz/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
YkfDoz/translation: 5d4f1c56f4e9e579ec9967ca68cc64ac
3bqt9U/message: 2366ed295eb2c03c425559c24cb31606
3bqt9U/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
3bqt9U/translation: 2366ed295eb2c03c425559c24cb31606
@@ -316,6 +329,9 @@ checksums:
1rWxEw/message: 122c108794f321c10e05a08f09b11457
1rWxEw/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
1rWxEw/translation: 122c108794f321c10e05a08f09b11457
iH8pgl/message: f541015a827e37cb3b1234e56bc2aa3c
iH8pgl/origin/0/0: 69d8634b177fdf937764b406fd477547
iH8pgl/translation: f541015a827e37cb3b1234e56bc2aa3c
KNKCTb/message: 35919fd5b10ad212839a75e72829fe4e
KNKCTb/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
KNKCTb/translation: 35919fd5b10ad212839a75e72829fe4e
@@ -334,6 +350,9 @@ checksums:
UoSI%2Bi/message: 47f1051a134e111930be7f94179df4c8
UoSI%2Bi/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
UoSI%2Bi/translation: 47f1051a134e111930be7f94179df4c8
zt%2F6cS/message: aadb69a7f7421be6bb022fbde08a6d77
zt%2F6cS/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
zt%2F6cS/translation: aadb69a7f7421be6bb022fbde08a6d77
qaS%2B1%2F/message: 4e25347caf65e1b63bec90e101b86ddd
qaS%2B1%2F/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
qaS%2B1%2F/translation: 4e25347caf65e1b63bec90e101b86ddd
@@ -360,8 +379,7 @@ checksums:
QD8opX/origin/2/0: faa8a2576972b4eb31565d8ea6098f43
QD8opX/translation: 101ff39aab674c033c15ab978d0d8bac
YhvWXb/message: 336a8c70a91adc49c15266167ee84cc0
YhvWXb/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
YhvWXb/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
YhvWXb/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
YhvWXb/translation: 336a8c70a91adc49c15266167ee84cc0
i0ZMQl/message: f739058266a2cb6a3d7cca14de09c69a
i0ZMQl/origin/0/0: 0ad2af981579a62b084b3d41462c5546
@@ -516,23 +534,28 @@ checksums:
dEgA5A/message: 2e2a849c2223911717de8caa2c71bade
dEgA5A/origin/0/0: cf5db0532c4f1cf49cebe78926c03385
dEgA5A/origin/1/0: a50c4fbde8da6e0a77c4e0aef0b44dea
dEgA5A/origin/2/0: 522726bb62051656dc0d165185d6b451
dEgA5A/origin/3/0: b2059fa4b8b99cc10440da26475b234c
dEgA5A/origin/4/0: 2a382c1cdf010f01a8e95c3e05f6df53
dEgA5A/origin/5/0: d55f532bb5ae247e3a018978be0da463
dEgA5A/origin/6/0: 4b4f55c033add8db69178563832227c6
dEgA5A/origin/7/0: 68450df287fb46ea629e818732c1291a
dEgA5A/origin/8/0: 03c00909b41be01231cd3e843c8ee44e
dEgA5A/origin/9/0: 0627e0040ca4939c9a57349e98c51797
dEgA5A/origin/10/0: 15792234f408027822ee71ccddde6f9b
dEgA5A/origin/11/0: 5c60161e173f5eae9276d8e4c70b4ebb
dEgA5A/origin/12/0: a1fde3fcce608870830f168f027727ff
dEgA5A/origin/13/0: 83911e3eacbad4583e2b1647a784d154
dEgA5A/origin/14/0: dbad0c8d7863cb41f5495264f82e7081
dEgA5A/origin/2/0: 97bd206a05414e2a571020d64e166093
dEgA5A/origin/3/0: 522726bb62051656dc0d165185d6b451
dEgA5A/origin/4/0: b2059fa4b8b99cc10440da26475b234c
dEgA5A/origin/5/0: 2a382c1cdf010f01a8e95c3e05f6df53
dEgA5A/origin/6/0: d55f532bb5ae247e3a018978be0da463
dEgA5A/origin/7/0: 4b4f55c033add8db69178563832227c6
dEgA5A/origin/8/0: 68450df287fb46ea629e818732c1291a
dEgA5A/origin/9/0: 15ea2dfd5eae4e05b226b1320103476e
dEgA5A/origin/10/0: 03c00909b41be01231cd3e843c8ee44e
dEgA5A/origin/11/0: 0627e0040ca4939c9a57349e98c51797
dEgA5A/origin/12/0: 15792234f408027822ee71ccddde6f9b
dEgA5A/origin/13/0: 5c60161e173f5eae9276d8e4c70b4ebb
dEgA5A/origin/14/0: a1fde3fcce608870830f168f027727ff
dEgA5A/origin/15/0: 83911e3eacbad4583e2b1647a784d154
dEgA5A/origin/16/0: dbad0c8d7863cb41f5495264f82e7081
dEgA5A/translation: 2e2a849c2223911717de8caa2c71bade
kryGs%2B/message: bba0beaced7ea954ceb980f2b022ffee
kryGs%2B/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
kryGs%2B/translation: bba0beaced7ea954ceb980f2b022ffee
sU2uWc/message: 3fe52244676faf64e5266b6713d01e07
sU2uWc/origin/0/0: 97bd206a05414e2a571020d64e166093
sU2uWc/translation: 3fe52244676faf64e5266b6713d01e07
fEY2vP/message: 91509e2f92b0b3b11330b6983139fdbf
fEY2vP/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
fEY2vP/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
@@ -541,8 +564,9 @@ checksums:
ZH7TVS/origin/0/0: 984122104bcc6d8fa6d4c8a35e7751d2
ZH7TVS/translation: 7c34f59f4005e6cb3a6ff546ea0b96e3
rQXTmd/message: 9387a4702b302f23f2f7c20b73a2b3dd
rQXTmd/origin/0/0: 1b05beba6bdd908015fa8e798717f024
rQXTmd/origin/1/0: 9c8b544f5db130cd7c5eda9064ba3957
rQXTmd/origin/0/0: fc0c18a095a68277a0f90a115942683b
rQXTmd/origin/1/0: 1b05beba6bdd908015fa8e798717f024
rQXTmd/origin/2/0: 9c8b544f5db130cd7c5eda9064ba3957
rQXTmd/translation: 9387a4702b302f23f2f7c20b73a2b3dd
VhMDMg/message: a552fc5c4189ebc3e2e6018edda7d18f
VhMDMg/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
@@ -550,9 +574,9 @@ checksums:
VhMDMg/origin/2/0: 0627e0040ca4939c9a57349e98c51797
VhMDMg/origin/3/0: 0627e0040ca4939c9a57349e98c51797
VhMDMg/translation: a552fc5c4189ebc3e2e6018edda7d18f
fzGyWs/translation: 25dbbca7a83f6af119179e7a56c953d4
fzGyWs/message: 25dbbca7a83f6af119179e7a56c953d4
fzGyWs/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
fzGyWs/translation: 25dbbca7a83f6af119179e7a56c953d4
yD3ZSw/message: 293d49fc3c75e9c425b64bd7126e6b46
yD3ZSw/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
yD3ZSw/translation: 293d49fc3c75e9c425b64bd7126e6b46
@@ -565,8 +589,9 @@ checksums:
pFGfsR/origin/0/0: 82383b95848d24b9311ceaef0ec94bb6
pFGfsR/translation: b0d0be4f63c16552b3aa795af29240d1
5IXWmO/message: e9a430fcd298def74212238df0f680d6
5IXWmO/origin/0/0: 4a1be3707d14c696a16f453d132fb248
5IXWmO/origin/1/0: d6f5120f072a821219608624f361384e
5IXWmO/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
5IXWmO/origin/1/0: 4a1be3707d14c696a16f453d132fb248
5IXWmO/origin/2/0: d6f5120f072a821219608624f361384e
5IXWmO/translation: e9a430fcd298def74212238df0f680d6
GnRmu4/message: 5eb5de823f7ca5a4d97bb41e6a3f675a
GnRmu4/origin/0/0: a2dd1fc876bd42b9db7ca7aaa57ac5e2
@@ -575,6 +600,13 @@ checksums:
EH8IL3/origin/0/0: c0b59968b2b234167c488a05cc38710b
EH8IL3/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
EH8IL3/translation: 6f79129c8f08ee54d858a2af57d16dd9
zMquA7/message: 38ff8d574865bb71d2f620bc4ba98c20
zMquA7/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
5EMoSo/translation: d5825bc247cfd3e52e233f7dab8051c6
5EMoSo/message: d5825bc247cfd3e52e233f7dab8051c6
5EMoSo/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
5EMoSo/origin/1/0: 619b4be43b9080346366c4bf6ba2253d
VHS0aJ/message: 31d0962985c83a29558c98a765bb1b16
VHS0aJ/origin/0/0: 15792234f408027822ee71ccddde6f9b
VHS0aJ/translation: 31d0962985c83a29558c98a765bb1b16
@@ -594,9 +626,10 @@ checksums:
RRn9jf/origin/1/0: d6f5120f072a821219608624f361384e
RRn9jf/translation: 210b6ff8727f976182ec3f29ea3c7667
yz7wBu/message: 2c2e22f8424a1031de89063bd0022e16
yz7wBu/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
yz7wBu/origin/1/0: afee530613110e248ffde6b56872ebed
yz7wBu/origin/2/0: dcafca531d4dce1faef7788feabfb048
yz7wBu/origin/0/0: 278a40eb45b1219f424703d0abc1d273
yz7wBu/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
yz7wBu/origin/2/0: afee530613110e248ffde6b56872ebed
yz7wBu/origin/3/0: dcafca531d4dce1faef7788feabfb048
yz7wBu/translation: 2c2e22f8424a1031de89063bd0022e16
TcXcmk/message: a2da6c2339301e7c3ddf068c4ff9f7e8
TcXcmk/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
@@ -608,9 +641,8 @@ checksums:
dtQIWT/origin/0/0: c0b59968b2b234167c488a05cc38710b
dtQIWT/translation: 9d2308d578ec21bd22374e880fe3bd3c
AUYALh/message: ee2b0671e00972773210c5be5a9ccb89
AUYALh/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
AUYALh/origin/1/0: d77eb1f90f2fa86a55181629d88d237a
AUYALh/origin/2/0: dbad0c8d7863cb41f5495264f82e7081
AUYALh/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
AUYALh/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
AUYALh/translation: ee2b0671e00972773210c5be5a9ccb89
VbyRUy/message: 502237cd1044491c48494aa60967a985
VbyRUy/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
@@ -680,6 +712,10 @@ checksums:
AX4ktp/message: 4e6be22ade0188f408471325fe363575
AX4ktp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
AX4ktp/translation: 4e6be22ade0188f408471325fe363575
xGVfLh/message: 3cfba90b4600131e82fc4260c568d044
xGVfLh/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
xGVfLh/origin/1/0: 69d8634b177fdf937764b406fd477547
xGVfLh/translation: 3cfba90b4600131e82fc4260c568d044
J2Wbjt/message: 8ed03cf7c5e60a6edf3470a4558ff058
J2Wbjt/origin/0/0: d96af8a06c2e38a72238bcd16e426859
J2Wbjt/translation: 8ed03cf7c5e60a6edf3470a4558ff058
@@ -699,6 +735,21 @@ checksums:
F0YmUY/message: 9def232c9163d109f0e9093684cfe75f
F0YmUY/origin/0/0: 1b05beba6bdd908015fa8e798717f024
F0YmUY/translation: 9def232c9163d109f0e9093684cfe75f
0utuU6/message: 9d7e453b24dfaf217352aeadc262074d
0utuU6/origin/0/0: 97bd206a05414e2a571020d64e166093
0utuU6/translation: 9d7e453b24dfaf217352aeadc262074d
fDQ7hA/message: 1d24ecbad9f2f0081ab51c7366e40506
fDQ7hA/origin/0/0: 97bd206a05414e2a571020d64e166093
fDQ7hA/translation: 1d24ecbad9f2f0081ab51c7366e40506
1bJKhj/message: f61803ac8da8253e4ba35f92b3af1101
1bJKhj/origin/0/0: 0eeeb7e68164ff118040918f1ef00a7a
1bJKhj/translation: f61803ac8da8253e4ba35f92b3af1101
uvH2xS/message: ecc2617544a6d385d18678577fa112fd
uvH2xS/origin/0/0: 97bd206a05414e2a571020d64e166093
uvH2xS/translation: ecc2617544a6d385d18678577fa112fd
7mrkyO/message: d3853b58e9b736eefb0f9bf400daf4d7
7mrkyO/origin/0/0: 1b05beba6bdd908015fa8e798717f024
7mrkyO/translation: d3853b58e9b736eefb0f9bf400daf4d7
9PaIxv/message: da95932e7a1465a5d21aa3b855a46dc2
9PaIxv/origin/0/0: c0b59968b2b234167c488a05cc38710b
9PaIxv/translation: da95932e7a1465a5d21aa3b855a46dc2
@@ -795,9 +846,11 @@ checksums:
7RpHg%2F/origin/0/0: c0b59968b2b234167c488a05cc38710b
7RpHg%2F/origin/1/0: c0b59968b2b234167c488a05cc38710b
7RpHg%2F/translation: 66f702512197d25cfdb117f2415ecd88
ZDPJBc/message: f9154d0c02a7db907f0a33fb5edd4be1
ZDPJBc/origin/0/0: 69d8634b177fdf937764b406fd477547
ZDPJBc/translation: f9154d0c02a7db907f0a33fb5edd4be1
j9IZZ0/message: 7ba841d0946eb04fa3d17d74365be37b
j9IZZ0/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
j9IZZ0/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
j9IZZ0/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
j9IZZ0/translation: 7ba841d0946eb04fa3d17d74365be37b
OwneeH/message: e97476d280d4ebfd196d6c5336f3511f
OwneeH/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
@@ -838,7 +891,8 @@ checksums:
tmpW%2Bw/origin/0/0: 0ad2af981579a62b084b3d41462c5546
tmpW%2Bw/translation: e915cd160651ad4b61a67e8da0737c23
nabda1/message: 18811ccf76fbb7a2772d648f7c5f0ea7
nabda1/origin/0/0: 1b05beba6bdd908015fa8e798717f024
nabda1/origin/0/0: 0eeeb7e68164ff118040918f1ef00a7a
nabda1/origin/1/0: 1b05beba6bdd908015fa8e798717f024
nabda1/translation: 18811ccf76fbb7a2772d648f7c5f0ea7
njJFtc/message: a3dae0c9ddbc73677aad9812e3dffd69
njJFtc/origin/0/0: b2059fa4b8b99cc10440da26475b234c
@@ -919,12 +973,15 @@ checksums:
vUr5JT/origin/0/0: 4a1be3707d14c696a16f453d132fb248
vUr5JT/translation: 6ae8282ab8b4a01b4ce87b119e13714b
DPfwMq/message: ffd408fa29d5bc9039ef8ea1b9b699bb
DPfwMq/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
DPfwMq/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
DPfwMq/origin/2/0: eb63312c63f6c8d2a5b6520c89012123
DPfwMq/origin/0/0: 81b173297e9c022d9ebde5f7018c95f9
DPfwMq/origin/1/0: 120e67abaa784f3adb192e7519919a81
DPfwMq/origin/2/0: fc68b5394b9b466d5d67df855060544d
DPfwMq/origin/3/0: eb63312c63f6c8d2a5b6520c89012123
DPfwMq/origin/4/0: eb63312c63f6c8d2a5b6520c89012123
DPfwMq/origin/5/0: eb63312c63f6c8d2a5b6520c89012123
DPfwMq/origin/6/0: eb63312c63f6c8d2a5b6520c89012123
DPfwMq/origin/7/0: eb63312c63f6c8d2a5b6520c89012123
DPfwMq/origin/8/0: eb63312c63f6c8d2a5b6520c89012123
DPfwMq/translation: ffd408fa29d5bc9039ef8ea1b9b699bb
R3FsF4/message: 142b391cca3e05f2af9617092f7358c5
R3FsF4/origin/0/0: 3a89a46ac678e34dc8eb687b07a4ad4b
@@ -946,6 +1003,16 @@ checksums:
zxKs%2By/message: 0b6c8ad7aba0873b7e212d5f1949ca97
zxKs%2By/origin/0/0: db1e755dc7d73e037f425111bf7c30a6
zxKs%2By/translation: 0b6c8ad7aba0873b7e212d5f1949ca97
euc6Ns/message: 27756566785c2b8463e21582c4bb619b
euc6Ns/origin/0/0: 97bd206a05414e2a571020d64e166093
euc6Ns/translation: 27756566785c2b8463e21582c4bb619b
BTDPLo/message: f6a33afcf5ff1489551c94867e083dca
BTDPLo/origin/0/0: 97bd206a05414e2a571020d64e166093
BTDPLo/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
BTDPLo/translation: f6a33afcf5ff1489551c94867e083dca
06EQqT/message: e9f56489fb395a890ae2557200c29a36
06EQqT/origin/0/0: 97bd206a05414e2a571020d64e166093
06EQqT/translation: e9f56489fb395a890ae2557200c29a36
ePK91l/message: eee7f39ff90b18852afc1671f21fbaa9
ePK91l/origin/0/0: b697a20a9536be4dd8ecd143461eafc7
ePK91l/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
@@ -992,6 +1059,12 @@ checksums:
IqjpYe/message: 81d41cf573a7109c376d30d905beb596
IqjpYe/origin/0/0: 5f6c4a0c9be5098099167871ae1564af
IqjpYe/translation: 81d41cf573a7109c376d30d905beb596
bFREhu/message: b2cc9a311fc28d1594252f697c7f5342
bFREhu/origin/0/0: 97bd206a05414e2a571020d64e166093
bFREhu/translation: b2cc9a311fc28d1594252f697c7f5342
SUXBxp/message: 76e841ae8ec3340d7d0a5c2191abbea4
SUXBxp/origin/0/0: 69d8634b177fdf937764b406fd477547
SUXBxp/translation: 76e841ae8ec3340d7d0a5c2191abbea4
0%2BewPp/message: 785fe23c0eef0a5b60b5b2a88151de31
0%2BewPp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
0%2BewPp/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
@@ -1061,6 +1134,9 @@ checksums:
rarRW%2F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
rarRW%2F/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
rarRW%2F/translation: 91553b606a6ebed3534cd2e5635ce004
sZixXc/message: db4e3e372abb1ac510c62b14d544f0f8
sZixXc/origin/0/0: 0627e0040ca4939c9a57349e98c51797
sZixXc/translation: db4e3e372abb1ac510c62b14d544f0f8
sVBn4K/message: 0d7979a25d290123ed8b8906bf468f1a
sVBn4K/origin/0/0: d465da67c0a870d198eb7f313569c137
sVBn4K/translation: 0d7979a25d290123ed8b8906bf468f1a
@@ -1074,8 +1150,7 @@ checksums:
x0kMO%2F/origin/0/0: 30791e26a172d47d3cb0693e82679246
x0kMO%2F/translation: 9da6c012e1b512d8c7fbd812626c4675
YaZDYI/message: 57cc47581c7faaffb3b1721b6352b576
YaZDYI/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
YaZDYI/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
YaZDYI/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
YaZDYI/translation: 57cc47581c7faaffb3b1721b6352b576
lsgnzd/message: 934c6867d9bdbe6099b2c0f5a3107f78
lsgnzd/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
@@ -1170,18 +1245,23 @@ checksums:
l31EoQ/message: b4c05c9174637f4da2a41ead0d65bc01
l31EoQ/origin/0/0: 4980d8e27a628e7cd9a70b839d844083
l31EoQ/translation: b4c05c9174637f4da2a41ead0d65bc01
q3il6U/translation: 79a4fffd70bfbedb6174ea4c767f4ab9
q3il6U/message: 79a4fffd70bfbedb6174ea4c767f4ab9
q3il6U/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
q3il6U/translation: 79a4fffd70bfbedb6174ea4c767f4ab9
"%2B3TYXa/message": 9fa1c942feb40d6350322b71561df789
"%2B3TYXa/origin/0/0": 4980d8e27a628e7cd9a70b839d844083
"%2B3TYXa/translation": 9fa1c942feb40d6350322b71561df789
2POOFK/message: 0326365539c004f6088656f692602078
2POOFK/origin/0/0: c0b59968b2b234167c488a05cc38710b
2POOFK/origin/1/0: 9737a9cef0646ff4bbd942b1924b9a2c
2POOFK/origin/2/0: 9737a9cef0646ff4bbd942b1924b9a2c
2POOFK/origin/3/0: fadd5a5b6963ec5f261072f7e8d2140b
2POOFK/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
2POOFK/origin/1/0: 15ea2dfd5eae4e05b226b1320103476e
2POOFK/origin/2/0: c0b59968b2b234167c488a05cc38710b
2POOFK/origin/3/0: 9737a9cef0646ff4bbd942b1924b9a2c
2POOFK/origin/4/0: 9737a9cef0646ff4bbd942b1924b9a2c
2POOFK/origin/5/0: fadd5a5b6963ec5f261072f7e8d2140b
2POOFK/translation: 0326365539c004f6088656f692602078
8LLt89/translation: 9a78178dc1874543d35153ae325e2811
8LLt89/message: 9a78178dc1874543d35153ae325e2811
8LLt89/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
uP4V6I/message: 2d403501f0488ec7d4eac979faf051a2
uP4V6I/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
uP4V6I/translation: 2d403501f0488ec7d4eac979faf051a2
@@ -1270,6 +1350,9 @@ checksums:
csFbe%2B/message: e4e58e33d637282d141df466d729bc7c
csFbe%2B/origin/0/0: 94d3e20c29bb517dd394711d7524e478
csFbe%2B/translation: e4e58e33d637282d141df466d729bc7c
SUvm1Y/message: 6c0f26526f77e9abb92fb2b1106a66db
SUvm1Y/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
SUvm1Y/translation: 6c0f26526f77e9abb92fb2b1106a66db
cdyS7J/message: 0f10050aeed2555a34fe3c27ce616607
cdyS7J/origin/0/0: c0b59968b2b234167c488a05cc38710b
cdyS7J/origin/1/0: c0b59968b2b234167c488a05cc38710b
@@ -1315,6 +1398,9 @@ checksums:
2Py5S%2F/message: fe1183a7439299210d8ab7379b8f4d01
2Py5S%2F/origin/0/0: 83911e3eacbad4583e2b1647a784d154
2Py5S%2F/translation: fe1183a7439299210d8ab7379b8f4d01
VOwhhz/message: 613f43526ea128b2749f70878f10008c
VOwhhz/origin/0/0: 1b05beba6bdd908015fa8e798717f024
VOwhhz/translation: 613f43526ea128b2749f70878f10008c
iwr7AP/message: 27c24b56895defecd10b0171b39bc93f
iwr7AP/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
iwr7AP/translation: 27c24b56895defecd10b0171b39bc93f
@@ -1399,6 +1485,9 @@ checksums:
"%2BdjOzj/origin/0/0": faa852a0c0e3c81fe948e394029c1ba5
"%2BdjOzj/origin/1/0": 8628ddb6f4ded25c0f17e2cbda8207d3
"%2BdjOzj/translation": ade922db1be6b26bc979565ce5de2bc7
M2zwdn/translation: 4c9c0d43434ad63a70e8f956d1900602
M2zwdn/message: 4c9c0d43434ad63a70e8f956d1900602
M2zwdn/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
qPP4lD/message: 03eeed2d715e770259f722ba48a61ab3
qPP4lD/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
qPP4lD/translation: 03eeed2d715e770259f722ba48a61ab3
@@ -1426,9 +1515,10 @@ checksums:
h7MgpO/origin/0/0: feacdaf2791aaddec128f463ffa2e807
h7MgpO/translation: ef00d7494b69def6841620bd6554d040
h8DugX/message: 6f15627a90002323eac018274b6922d6
h8DugX/origin/0/0: db1e755dc7d73e037f425111bf7c30a6
h8DugX/origin/1/0: 984122104bcc6d8fa6d4c8a35e7751d2
h8DugX/origin/2/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
h8DugX/origin/0/0: 120e67abaa784f3adb192e7519919a81
h8DugX/origin/1/0: db1e755dc7d73e037f425111bf7c30a6
h8DugX/origin/2/0: 984122104bcc6d8fa6d4c8a35e7751d2
h8DugX/origin/3/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
h8DugX/translation: 6f15627a90002323eac018274b6922d6
AZI%2Fwb/message: e1ceda0c6cc32fb04cb9423582ad8fe4
AZI%2Fwb/origin/0/0: c0b59968b2b234167c488a05cc38710b
@@ -1439,13 +1529,12 @@ checksums:
vXIe7J/message: 277fd1a41cc237a437cd1d5e4a80463b
vXIe7J/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
vXIe7J/translation: 277fd1a41cc237a437cd1d5e4a80463b
k7rCa%2F/translation: 890b7851af05f97ac53644e4154c13b4
k7rCa%2F/message: 890b7851af05f97ac53644e4154c13b4
k7rCa%2F/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
k7rCa%2F/translation: 890b7851af05f97ac53644e4154c13b4
8Is1ih/message: 624592ba2ecf647a3fe60d493c1ee71a
8Is1ih/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
8Is1ih/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
8Is1ih/origin/2/0: dbad0c8d7863cb41f5495264f82e7081
8Is1ih/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
8Is1ih/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
8Is1ih/translation: 624592ba2ecf647a3fe60d493c1ee71a
EEHmPC/message: 8c1be80f5b2fcc112922282afbe30287
EEHmPC/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
@@ -1465,6 +1554,12 @@ checksums:
iQmbPb/message: ac2bcac75151e2fcd9eb768cea275a77
iQmbPb/origin/0/0: 98e2302d0d8d12ce65c4140b87450673
iQmbPb/translation: ac2bcac75151e2fcd9eb768cea275a77
aDzbuM/message: b6cb6ee4c4dd2cbe0d5d6a0088dcd731
aDzbuM/origin/0/0: 278a40eb45b1219f424703d0abc1d273
aDzbuM/translation: b6cb6ee4c4dd2cbe0d5d6a0088dcd731
K3LUJr/message: 6dd9358a22160ebed51ab3f36e2bb0b1
K3LUJr/origin/0/0: 278a40eb45b1219f424703d0abc1d273
K3LUJr/translation: 6dd9358a22160ebed51ab3f36e2bb0b1
1njn7W/message: bb78ea2c6edd8662676c81a8a13ecb93
1njn7W/origin/0/0: 358ea1b5babb2e9ccbf1a4c9f89dd3a5
1njn7W/translation: bb78ea2c6edd8662676c81a8a13ecb93
@@ -1473,12 +1568,14 @@ checksums:
TCt%2F8K/translation: ffd8033392a01d7b33f13afa51bccb9a
edWbV6/message: 4a4805700b31ec65bcb3ca962bccfd1b
edWbV6/origin/0/0: 85d0a3237fa9329fba3f0278a2cd7284
edWbV6/origin/1/0: 1b05beba6bdd908015fa8e798717f024
edWbV6/origin/2/0: 9c8b544f5db130cd7c5eda9064ba3957
edWbV6/origin/3/0: faa8a2576972b4eb31565d8ea6098f43
edWbV6/origin/1/0: fc0c18a095a68277a0f90a115942683b
edWbV6/origin/2/0: 1b05beba6bdd908015fa8e798717f024
edWbV6/origin/3/0: 9c8b544f5db130cd7c5eda9064ba3957
edWbV6/origin/4/0: faa8a2576972b4eb31565d8ea6098f43
edWbV6/translation: 4a4805700b31ec65bcb3ca962bccfd1b
2BBAbc/message: 94f13e7ef909a4de9db7abaa1f9f0b61
2BBAbc/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
2BBAbc/origin/0/0: 97bd206a05414e2a571020d64e166093
2BBAbc/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
2BBAbc/translation: 94f13e7ef909a4de9db7abaa1f9f0b61
zQX5Dj/message: e925e2e6ccaf0eb4064a888aaea8d3c2
zQX5Dj/origin/0/0: 5e445d66b146e0ca392faa35098701c4
@@ -1516,6 +1613,10 @@ checksums:
DbZgsJ/message: 7a1c63b75cea690b3fda6c3f203e31ff
DbZgsJ/origin/0/0: 0ad2af981579a62b084b3d41462c5546
DbZgsJ/translation: 7a1c63b75cea690b3fda6c3f203e31ff
hB02vO/message: c13aa2a155ab4aff1c8fed6650a94e1a
hB02vO/origin/0/0: fc68b5394b9b466d5d67df855060544d
hB02vO/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
hB02vO/translation: c13aa2a155ab4aff1c8fed6650a94e1a
IDvohQ/message: 35d0822f65971b97774b962561b02649
IDvohQ/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
IDvohQ/translation: 35d0822f65971b97774b962561b02649
@@ -1523,17 +1624,26 @@ checksums:
jl3aEJ/placeholders/0/0: 06f23520ebabfffed4999b5bcf61c9cd
jl3aEJ/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
jl3aEJ/translation: 4c38799ff25321ea25017bf4cd8e2e4f
agPptk/translation: 43983f2178fb28aacaef6c5f41282c4a
vo2a%2Ba/message: fcf0f06f8b64b458c7ca6d95541a3cc8
vo2a%2Ba/origin/0/0: 69d8634b177fdf937764b406fd477547
vo2a%2Ba/translation: fcf0f06f8b64b458c7ca6d95541a3cc8
agPptk/message: 43983f2178fb28aacaef6c5f41282c4a
agPptk/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
agPptk/translation: 43983f2178fb28aacaef6c5f41282c4a
W%2FElkg/message: 1f527cd6d1ed602930bcaa303f503b51
W%2FElkg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
W%2FElkg/translation: 1f527cd6d1ed602930bcaa303f503b51
NwiNTb/translation: 9840dd63c40b860a9de1488a07b86828
NwiNTb/message: 9840dd63c40b860a9de1488a07b86828
NwiNTb/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
OvoEq7/message: 1606dc30b369856b9dba1fe9aec425d2
OvoEq7/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
OvoEq7/origin/1/0: 1d6b5f88cdad160a67842f6b65387448
OvoEq7/origin/2/0: 7493592314bc3b96ad96127a949ba1ba
OvoEq7/translation: 1606dc30b369856b9dba1fe9aec425d2
GnG6Oy/translation: c7910e9020804dbe5c7022044c669e68
GnG6Oy/message: c7910e9020804dbe5c7022044c669e68
GnG6Oy/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
wlQNTg/message: 0932e80cba1e3e0a7f52bb67ff31da32
wlQNTg/origin/0/0: 94d3e20c29bb517dd394711d7524e478
wlQNTg/origin/1/0: db1e755dc7d73e037f425111bf7c30a6
@@ -1553,12 +1663,19 @@ checksums:
hty0d5/origin/0/0: 04c86ea96f180745739d59e822da4026
hty0d5/translation: b4b1c8396e445e8b7956c747da2cf601
"%2B8Nek%2F/message": 818f1192e32bb855597f930d3e78806e
"%2B8Nek%2F/origin/0/0": 9737a9cef0646ff4bbd942b1924b9a2c
"%2B8Nek%2F/origin/1/0": 82a5cf8d69f098641eb357bf102cd504
"%2B8Nek%2F/origin/0/0": 15ea2dfd5eae4e05b226b1320103476e
"%2B8Nek%2F/origin/1/0": 9737a9cef0646ff4bbd942b1924b9a2c
"%2B8Nek%2F/origin/2/0": 82a5cf8d69f098641eb357bf102cd504
"%2B8Nek%2F/translation": 818f1192e32bb855597f930d3e78806e
vneRvS/message: e228107df80015377112d41f4f155cb3
vneRvS/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
vneRvS/translation: e228107df80015377112d41f4f155cb3
51UCsN/message: ce14fde5e972c2b5276c25ddded817c4
51UCsN/origin/0/0: 0eeeb7e68164ff118040918f1ef00a7a
51UCsN/translation: ce14fde5e972c2b5276c25ddded817c4
J4%2BOTA/message: 6ad27e803483208b5313660b7b5791d3
J4%2BOTA/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
J4%2BOTA/translation: 6ad27e803483208b5313660b7b5791d3
BOqTi5/message: 11edf0427766c26db541d46379dc3c16
BOqTi5/placeholders/0/0: 9db68a4b386b9fa1fd1d5797c32be100
BOqTi5/placeholders/1/0: b837a9e8bd06b58be95699b3ee663f43
@@ -1659,6 +1776,9 @@ checksums:
TXO5TM/message: 7f1ed5d777cade7d62303e9e591bbf63
TXO5TM/origin/0/0: feacdaf2791aaddec128f463ffa2e807
TXO5TM/translation: 7f1ed5d777cade7d62303e9e591bbf63
HOMO91/message: 8a7b468d8a18e30de7743e4d5841c87f
HOMO91/origin/0/0: 278a40eb45b1219f424703d0abc1d273
HOMO91/translation: 8a7b468d8a18e30de7743e4d5841c87f
hXMFoN/message: cedf633d99c77ff4356e089f2d98c0a6
hXMFoN/origin/0/0: fc0c18a095a68277a0f90a115942683b
hXMFoN/translation: cedf633d99c77ff4356e089f2d98c0a6
@@ -1733,6 +1853,9 @@ checksums:
Uworke/message: 213d63da450f35dabb3ab0e35e29feed
Uworke/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
Uworke/translation: 213d63da450f35dabb3ab0e35e29feed
nG7mus/message: 03326832c645f4ddb873a6e7eab8a639
nG7mus/origin/0/0: 278a40eb45b1219f424703d0abc1d273
nG7mus/translation: 03326832c645f4ddb873a6e7eab8a639
IrZaAn/message: 1fcebe9ddb46f722a57f195efddc695d
IrZaAn/origin/0/0: 0627e0040ca4939c9a57349e98c51797
IrZaAn/translation: 1fcebe9ddb46f722a57f195efddc695d
@@ -1745,6 +1868,9 @@ checksums:
vwGkYB/message: 4c30501d085eaccea47af34212bb26a7
vwGkYB/origin/0/0: 0627e0040ca4939c9a57349e98c51797
vwGkYB/translation: 4c30501d085eaccea47af34212bb26a7
Xlkxmi/message: 3251e7239a9b349a86d5785a113cd6b9
Xlkxmi/origin/0/0: 0627e0040ca4939c9a57349e98c51797
Xlkxmi/translation: 3251e7239a9b349a86d5785a113cd6b9
lpIMne/message: 37ca1f4e0afc9a0b8e9617f767103c92
lpIMne/origin/0/0: 0627e0040ca4939c9a57349e98c51797
lpIMne/translation: 37ca1f4e0afc9a0b8e9617f767103c92
@@ -1786,6 +1912,12 @@ checksums:
8RdLDU/message: d7820b1bf4efecc61ed89234567aaa9c
8RdLDU/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
8RdLDU/translation: d7820b1bf4efecc61ed89234567aaa9c
Oi%2BJ%2BN/message: 313804fe615d51e0f0cbd599161d1ade
Oi%2BJ%2BN/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
GdgCoi/translation: c6a08c771f97d86b02cf10d8f5f2d788
GdgCoi/message: c6a08c771f97d86b02cf10d8f5f2d788
GdgCoi/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
Iqh0Uv/message: 0cc9dcd6ac624383e9977f66d4b3b777
Iqh0Uv/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
Iqh0Uv/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
@@ -1856,27 +1988,35 @@ checksums:
tyHiOa/origin/34/0: afee530613110e248ffde6b56872ebed
tyHiOa/origin/35/0: afee530613110e248ffde6b56872ebed
tyHiOa/origin/36/0: 8628ddb6f4ded25c0f17e2cbda8207d3
tyHiOa/origin/37/0: 8628ddb6f4ded25c0f17e2cbda8207d3
tyHiOa/origin/38/0: 7493592314bc3b96ad96127a949ba1ba
tyHiOa/origin/39/0: 03c00909b41be01231cd3e843c8ee44e
tyHiOa/origin/40/0: 5c60161e173f5eae9276d8e4c70b4ebb
tyHiOa/origin/41/0: 83911e3eacbad4583e2b1647a784d154
tyHiOa/origin/42/0: d465da67c0a870d198eb7f313569c137
tyHiOa/origin/43/0: 33bf266228f835e12c0bfb0e5efb3be9
tyHiOa/origin/44/0: ff48991e314b0b3e989a4dac59860fd5
tyHiOa/origin/45/0: 30791e26a172d47d3cb0693e82679246
tyHiOa/origin/46/0: dbad0c8d7863cb41f5495264f82e7081
tyHiOa/origin/47/0: 0dccc39d0debbf3e089cb6d292de762b
tyHiOa/origin/37/0: 7493592314bc3b96ad96127a949ba1ba
tyHiOa/origin/38/0: 69d8634b177fdf937764b406fd477547
tyHiOa/origin/39/0: 69d8634b177fdf937764b406fd477547
tyHiOa/origin/40/0: 03c00909b41be01231cd3e843c8ee44e
tyHiOa/origin/41/0: 5c60161e173f5eae9276d8e4c70b4ebb
tyHiOa/origin/42/0: 83911e3eacbad4583e2b1647a784d154
tyHiOa/origin/43/0: d465da67c0a870d198eb7f313569c137
tyHiOa/origin/44/0: 33bf266228f835e12c0bfb0e5efb3be9
tyHiOa/origin/45/0: ff48991e314b0b3e989a4dac59860fd5
tyHiOa/origin/46/0: 30791e26a172d47d3cb0693e82679246
tyHiOa/origin/47/0: dbad0c8d7863cb41f5495264f82e7081
tyHiOa/origin/48/0: 0dccc39d0debbf3e089cb6d292de762b
tyHiOa/translation: 21ffcf0b00e7cd7b64f7454a95762e1d
PZCqeW/message: 325dea6dd0348a27a6818db2c1340c98
PZCqeW/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
PZCqeW/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
PZCqeW/translation: 325dea6dd0348a27a6818db2c1340c98
fuwKpE/message: 346a18a54564881c888b95873d03df44
fuwKpE/origin/0/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/1/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/2/0: faa8a2576972b4eb31565d8ea6098f43
fuwKpE/origin/0/0: 97bd206a05414e2a571020d64e166093
fuwKpE/origin/1/0: fd0e66cef8d46e96bcf3143a12f9b6db
fuwKpE/origin/2/0: fc0c18a095a68277a0f90a115942683b
fuwKpE/origin/3/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/4/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/5/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/6/0: faa8a2576972b4eb31565d8ea6098f43
fuwKpE/translation: 346a18a54564881c888b95873d03df44
nYNMZZ/message: d5a8a1b38eaf02d02cd6ac2211b42a92
nYNMZZ/origin/0/0: 97bd206a05414e2a571020d64e166093
nYNMZZ/translation: d5a8a1b38eaf02d02cd6ac2211b42a92
aHCEmh/message: ce27f1aeacccc542a174c4b2bce022b0
aHCEmh/origin/0/0: 98e2302d0d8d12ce65c4140b87450673
aHCEmh/origin/1/0: 634473ef965836871efe40875a36b6ab
@@ -1898,8 +2038,9 @@ checksums:
zwBp5t/origin/0/0: d6920b20329a9efe5985a39a295d83e1
zwBp5t/translation: d740d0660e68c61db1853489356030ae
3fPjUY/message: 682b3c9feab30112b4454cb5bb7974b1
3fPjUY/origin/0/0: c0b59968b2b234167c488a05cc38710b
3fPjUY/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
3fPjUY/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
3fPjUY/origin/1/0: c0b59968b2b234167c488a05cc38710b
3fPjUY/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
3fPjUY/translation: 682b3c9feab30112b4454cb5bb7974b1
oyRjD%2F/message: 76a09fd843b496610f8cf1dced417b62
oyRjD%2F/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
@@ -2009,6 +2150,7 @@ checksums:
"%2FgaSVU/origin/0/0": 98e2302d0d8d12ce65c4140b87450673
"%2FgaSVU/origin/1/0": 634473ef965836871efe40875a36b6ab
"%2FgaSVU/origin/2/0": 634473ef965836871efe40875a36b6ab
"%2FgaSVU/origin/3/0": 69d8634b177fdf937764b406fd477547
"%2FgaSVU/translation": c60f4a1acf30e566861bf130f13b9ae7
GDvlUT/message: 53743bbb6ca938f5b893552e839d067f
GDvlUT/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
@@ -2045,6 +2187,9 @@ checksums:
SkCNhl/message: 137017b2b3e885c4894ca8c899af5bc2
SkCNhl/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
SkCNhl/translation: 137017b2b3e885c4894ca8c899af5bc2
MpFIca/translation: 6e3f470ef5468c5195ae09f518b4dea4
MpFIca/message: 6e3f470ef5468c5195ae09f518b4dea4
MpFIca/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
e1v%2BJ3/message: 9aeca4f286e14966ef1c32d2abc74584
e1v%2BJ3/origin/0/0: f64cd62b620f454998523af2a4330278
e1v%2BJ3/translation: 9aeca4f286e14966ef1c32d2abc74584
@@ -2057,6 +2202,9 @@ checksums:
K3iLeO/message: e3bf6f95419169f460fb6918a0afc512
K3iLeO/origin/0/0: 5e2ed4920ffd2f535ece848ff44900bd
K3iLeO/translation: e3bf6f95419169f460fb6918a0afc512
zYRVNp/message: 8b36455dd5b43e56d2ab06e522c3bacc
zYRVNp/origin/0/0: 97bd206a05414e2a571020d64e166093
zYRVNp/translation: 8b36455dd5b43e56d2ab06e522c3bacc
wgNoIs/message: eedc7cdb02de467c15dc418a066a77f2
wgNoIs/origin/0/0: 3c57a918258af4b44c187f58d203345f
wgNoIs/origin/1/0: 3c57a918258af4b44c187f58d203345f
@@ -2085,12 +2233,26 @@ checksums:
v3YoMA/message: 3fff865dc00435f82896fc302ea45630
v3YoMA/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
v3YoMA/translation: 3fff865dc00435f82896fc302ea45630
GXGbXi/message: 8686cb61f9bd3cd7ed100cc3af819458
GXGbXi/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
GXGbXi/translation: 8686cb61f9bd3cd7ed100cc3af819458
8AbRER/message: 3cb81c4829857556f5d117c9a23f7bcc
8AbRER/origin/0/0: 2201bf679c663a732d7f1b3c3ce3897f
8AbRER/origin/0/0: 81b173297e9c022d9ebde5f7018c95f9
8AbRER/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
8AbRER/origin/2/0: 2201bf679c663a732d7f1b3c3ce3897f
8AbRER/translation: 3cb81c4829857556f5d117c9a23f7bcc
2gHjVM/message: 873c4eb14a7e32e7fd7793047b6028a1
2gHjVM/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
2gHjVM/origin/1/0: 627b72cd19a7154ae1c28cbd7ca0c730
2gHjVM/origin/2/0: 0627e0040ca4939c9a57349e98c51797
2gHjVM/origin/3/0: 0627e0040ca4939c9a57349e98c51797
2gHjVM/translation: 873c4eb14a7e32e7fd7793047b6028a1
Eit43O/message: b5d9a1edd988f455041aee9fa7fb3d63
Eit43O/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
Eit43O/translation: b5d9a1edd988f455041aee9fa7fb3d63
JjEJSy/message: 0154ff378b3f4329871378229e269fc7
JjEJSy/origin/0/0: 69d8634b177fdf937764b406fd477547
JjEJSy/translation: 0154ff378b3f4329871378229e269fc7
Tz0i8g/message: 8df6777277469c1fd88cc18dde2f1cc3
Tz0i8g/origin/0/0: 77c9f6139024b2069e4fdc5120f69cb5
Tz0i8g/origin/1/0: 94d3e20c29bb517dd394711d7524e478
@@ -2125,6 +2287,17 @@ checksums:
n1ekoW/message: ec7b8f314fe9bc6591006707484ede61
n1ekoW/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
n1ekoW/translation: ec7b8f314fe9bc6591006707484ede61
jbGGzf/translation: 7f18a6418cc7eb05158cff67247bc966
jbGGzf/message: 7f18a6418cc7eb05158cff67247bc966
jbGGzf/placeholders/partnerName/0: f182b91b0442585d06f8bf9545912628
jbGGzf/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
4dGN6E/message: dd086c229fd7cc0247d4c1b5c6f096a8
4dGN6E/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
4dGN6E/translation: dd086c229fd7cc0247d4c1b5c6f096a8
fcWrnU/message: 6d842546a84e919c519bf8cbffdf25a0
fcWrnU/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
fcWrnU/origin/1/0: 69d8634b177fdf937764b406fd477547
fcWrnU/translation: 6d842546a84e919c519bf8cbffdf25a0
mErq7F/message: 0dd2ae69be4618c1f9e615774a4509ca
mErq7F/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
mErq7F/translation: 0dd2ae69be4618c1f9e615774a4509ca
@@ -2151,15 +2324,24 @@ checksums:
zEcnBA/message: 513c3b4d9f0fda60fe079d42dcbd3882
zEcnBA/origin/0/0: c0b59968b2b234167c488a05cc38710b
zEcnBA/translation: 513c3b4d9f0fda60fe079d42dcbd3882
4Revpc/translation: 3adb3ae7891c5736776df840cff2360b
4Revpc/message: 3adb3ae7891c5736776df840cff2360b
4Revpc/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
4Revpc/translation: 3adb3ae7891c5736776df840cff2360b
"%2B%2BrVAm/message": 2227508afb0d38a027e323207e47d4a2
"%2B%2BrVAm/origin/0/0": eb63312c63f6c8d2a5b6520c89012123
"%2B%2BrVAm/translation": 2227508afb0d38a027e323207e47d4a2
wIb7Ng/message: 67659097ef6bcb194839858b2c4f3a3c
wIb7Ng/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
wIb7Ng/translation: 67659097ef6bcb194839858b2c4f3a3c
6sKjjx/message: 50861c0881fe87a77412417e5ffb3fbb
6sKjjx/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
6sKjjx/translation: 50861c0881fe87a77412417e5ffb3fbb
J9%2BzIR/message: d719cefeec51f2a6dd01064715a1fccc
J9%2BzIR/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
J9%2BzIR/translation: d719cefeec51f2a6dd01064715a1fccc
r%2BdxEH/message: c3d9e1523d1401870fbf56fa8bce8f8b
r%2BdxEH/origin/0/0: 278a40eb45b1219f424703d0abc1d273
r%2BdxEH/translation: c3d9e1523d1401870fbf56fa8bce8f8b
vnS6Rf/message: 003f7f93d44b0f5c2e40bf24e7af3bd7
vnS6Rf/origin/0/0: c0b59968b2b234167c488a05cc38710b
vnS6Rf/translation: 003f7f93d44b0f5c2e40bf24e7af3bd7
@@ -2167,9 +2349,13 @@ checksums:
R6pB8R/origin/0/0: 09a6a32f9a6b415e81b4d2f8181a0e6b
R6pB8R/translation: e5cb0428f1ac6485688f120d78742875
veIDCY/message: 4be2811a8f3455618032ccb59e40658b
veIDCY/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
veIDCY/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
veIDCY/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
veIDCY/translation: 4be2811a8f3455618032ccb59e40658b
4%2BJaWo/translation: e346e4ed7d138dcc873db187922369da
4%2BJaWo/message: e346e4ed7d138dcc873db187922369da
4%2BJaWo/origin/0/0: 94d3e20c29bb517dd394711d7524e478
4%2BJaWo/origin/1/0: 94d3e20c29bb517dd394711d7524e478
4%2BJaWo/origin/2/0: 94d3e20c29bb517dd394711d7524e478
Y8yzGg/message: 396a94648584067cc5795f7b32d3d663
Y8yzGg/origin/0/0: 82a5cf8d69f098641eb357bf102cd504
Y8yzGg/translation: 396a94648584067cc5795f7b32d3d663
@@ -2199,6 +2385,9 @@ checksums:
D%2BNlUC/message: 803281c327a02a2feba182f4a2b38311
D%2BNlUC/origin/0/0: 358ea1b5babb2e9ccbf1a4c9f89dd3a5
D%2BNlUC/translation: 803281c327a02a2feba182f4a2b38311
KM6m8p/message: c621ea9404a37af289def443b309bf1b
KM6m8p/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
bff61F/message: 3f0ae9c914f16ceb9a0d7db868f4aa20
bff61F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
bff61F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
@@ -2234,6 +2423,14 @@ checksums:
NnH3pK/message: 82a46f197779af4dde92e7e5f9336ccf
NnH3pK/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
NnH3pK/translation: 82a46f197779af4dde92e7e5f9336ccf
InJcdo/message: 1b2364c95fd318d99cc502745beac94a
InJcdo/origin/0/0: f64cd62b620f454998523af2a4330278
InJcdo/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
InJcdo/translation: 1b2364c95fd318d99cc502745beac94a
zDNL2V/message: 6e053a87e63372e903c16a06de25c9a3
zDNL2V/origin/0/0: f64cd62b620f454998523af2a4330278
zDNL2V/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
zDNL2V/translation: 6e053a87e63372e903c16a06de25c9a3
itgYbC/message: efe14181bc517316e3ab3258d72d2ff9
itgYbC/origin/0/0: f64cd62b620f454998523af2a4330278
itgYbC/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
@@ -2244,12 +2441,18 @@ checksums:
nhMhRr/message: 07edd8c50685a52c0969d711df26d768
nhMhRr/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
nhMhRr/translation: 07edd8c50685a52c0969d711df26d768
ssK7k%2B/message: 4321c1f033c350419ad198aaafe3b6fd
ssK7k%2B/origin/0/0: 278a40eb45b1219f424703d0abc1d273
ssK7k%2B/translation: 4321c1f033c350419ad198aaafe3b6fd
NcFrgC/message: bd15a7b3d8b4bce18cd42682ea1ec164
NcFrgC/origin/0/0: 0ad2af981579a62b084b3d41462c5546
NcFrgC/translation: bd15a7b3d8b4bce18cd42682ea1ec164
C6gv54/message: 1573c77059bc92df17e661befda8d9b7
C6gv54/origin/0/0: 0ad2af981579a62b084b3d41462c5546
C6gv54/translation: 1573c77059bc92df17e661befda8d9b7
nKBUeF/message: 07e3ce10820b8ab28643e38b2ed51f64
nKBUeF/origin/0/0: 97bd206a05414e2a571020d64e166093
nKBUeF/translation: 07e3ce10820b8ab28643e38b2ed51f64
jlB2RY/message: 67a76bf346ab4b48563269e9d941a64a
jlB2RY/origin/0/0: 0627e0040ca4939c9a57349e98c51797
jlB2RY/translation: 67a76bf346ab4b48563269e9d941a64a
@@ -2301,6 +2504,12 @@ checksums:
vlxQFL/message: 730f33b8f1fc002c4f393ccd262b6664
vlxQFL/origin/0/0: afee530613110e248ffde6b56872ebed
vlxQFL/translation: 730f33b8f1fc002c4f393ccd262b6664
HQVm6e/message: ec66b543a593e9dabf47de5979892d8d
HQVm6e/origin/0/0: 69d8634b177fdf937764b406fd477547
HQVm6e/translation: ec66b543a593e9dabf47de5979892d8d
gKmoow/message: 83997c77cb0dc8c8c90f7ab706aa817f
gKmoow/origin/0/0: 69d8634b177fdf937764b406fd477547
gKmoow/translation: 83997c77cb0dc8c8c90f7ab706aa817f
OAYToL/message: 1c989752736fa41ecdd68ea890f16a15
OAYToL/origin/0/0: 15792234f408027822ee71ccddde6f9b
OAYToL/translation: 1c989752736fa41ecdd68ea890f16a15
@@ -2319,9 +2528,15 @@ checksums:
kp6L3T/message: b7eadb89c615874f416d9658d0428c4c
kp6L3T/origin/0/0: 30791e26a172d47d3cb0693e82679246
kp6L3T/translation: b7eadb89c615874f416d9658d0428c4c
pEb95p/message: 1d4c2a0ec7e4c707a4470fa4262b495d
pEb95p/origin/0/0: 1b05beba6bdd908015fa8e798717f024
pEb95p/translation: 1d4c2a0ec7e4c707a4470fa4262b495d
MHrjPM/message: 344e64395eaff6822a57d18623853e1a
MHrjPM/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
MHrjPM/translation: 344e64395eaff6822a57d18623853e1a
wK4OTM/message: b2a89bfd8e13d77f626e84840602a603
wK4OTM/origin/0/0: 97bd206a05414e2a571020d64e166093
wK4OTM/translation: b2a89bfd8e13d77f626e84840602a603
Se%2FUIp/message: d60813ea824f373462471e092d136eed
Se%2FUIp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
Se%2FUIp/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
@@ -2363,10 +2578,14 @@ checksums:
LeM5RY/message: dfeac280858332082ad34d2623a59863
LeM5RY/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
LeM5RY/translation: dfeac280858332082ad34d2623a59863
5MPY04/message: 661f2e21a85fbe1281b141a8041c8778
5MPY04/origin/0/0: 1b05beba6bdd908015fa8e798717f024
5MPY04/translation: 661f2e21a85fbe1281b141a8041c8778
W1ewR0/message: ddcac5d14d48358b043a9487b42eec16
W1ewR0/origin/0/0: 1b05beba6bdd908015fa8e798717f024
W1ewR0/origin/1/0: 9c8b544f5db130cd7c5eda9064ba3957
W1ewR0/origin/2/0: faa8a2576972b4eb31565d8ea6098f43
W1ewR0/origin/0/0: fc0c18a095a68277a0f90a115942683b
W1ewR0/origin/1/0: 1b05beba6bdd908015fa8e798717f024
W1ewR0/origin/2/0: 9c8b544f5db130cd7c5eda9064ba3957
W1ewR0/origin/3/0: faa8a2576972b4eb31565d8ea6098f43
W1ewR0/translation: ddcac5d14d48358b043a9487b42eec16
zl1b97/message: 90112ea12ec6fa42097a6e022ae048a4
zl1b97/origin/0/0: 984122104bcc6d8fa6d4c8a35e7751d2
@@ -2381,8 +2600,12 @@ checksums:
YcyP2z/origin/0/0: 3161dd30737e7d849d4ccc30a28bada9
YcyP2z/origin/1/0: 3161dd30737e7d849d4ccc30a28bada9
YcyP2z/translation: 7495f1434c9188849bffd015811d5337
c1vOQR/message: 640ab5162eed616a7235d68376a016ed
c1vOQR/origin/0/0: f64cd62b620f454998523af2a4330278
c1vOQR/translation: 640ab5162eed616a7235d68376a016ed
MxQXhL/message: c1c949477e121d73ed6683eddf9cd874
MxQXhL/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
MxQXhL/origin/1/0: 69d8634b177fdf937764b406fd477547
MxQXhL/translation: c1c949477e121d73ed6683eddf9cd874
WHHLh9/message: 99d0b17b78d44e15f13d876d155f0efc
WHHLh9/origin/0/0: 3a89a46ac678e34dc8eb687b07a4ad4b
@@ -2399,6 +2622,15 @@ checksums:
2QGEbi/message: 550198b2c87f06726a843c79c1026ed9
2QGEbi/origin/0/0: 4b4f55c033add8db69178563832227c6
2QGEbi/translation: 550198b2c87f06726a843c79c1026ed9
23nvb2/message: b9dedce59b5594ee2a88bf5d55d6dfac
23nvb2/origin/0/0: a1fde3fcce608870830f168f027727ff
23nvb2/translation: b9dedce59b5594ee2a88bf5d55d6dfac
Oo4E6p/message: af985cf72c47df76e2f2139a427d9948
Oo4E6p/origin/0/0: 97bd206a05414e2a571020d64e166093
Oo4E6p/translation: af985cf72c47df76e2f2139a427d9948
K7k9u3/message: 1cf9d227042f6a65adba38cb7296830d
K7k9u3/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
K7k9u3/translation: 1cf9d227042f6a65adba38cb7296830d
8yFGGF/message: 39025a0c53818438829603d213baef06
8yFGGF/origin/0/0: 68450df287fb46ea629e818732c1291a
8yFGGF/translation: 39025a0c53818438829603d213baef06
@@ -2411,6 +2643,13 @@ checksums:
A8jqN7/message: 656c93265d7e2bef1245b17d85f85ae5
A8jqN7/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
A8jqN7/translation: 656c93265d7e2bef1245b17d85f85ae5
Q60WUZ/message: 4284c33aed2f2c5e004ed0a7d7d9bf87
Q60WUZ/origin/0/0: 69d8634b177fdf937764b406fd477547
Q60WUZ/translation: 4284c33aed2f2c5e004ed0a7d7d9bf87
KNK33q/message: a606d59661b59b5e2e9ab58e8cbc143b
KNK33q/origin/0/0: f64cd62b620f454998523af2a4330278
KNK33q/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
KNK33q/translation: a606d59661b59b5e2e9ab58e8cbc143b
T7Mywq/message: 62899897d7bffcb1df3af77d460f0881
T7Mywq/origin/0/0: 0ad2af981579a62b084b3d41462c5546
T7Mywq/translation: 62899897d7bffcb1df3af77d460f0881
@@ -2453,6 +2692,9 @@ checksums:
TFv5tM/message: 4f2240aeff6f7275feb33ca3f608e48c
TFv5tM/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
TFv5tM/translation: 4f2240aeff6f7275feb33ca3f608e48c
V1o9Jo/message: 7b212f1c2fdc370974f859b08349b0c6
V1o9Jo/origin/0/0: f64cd62b620f454998523af2a4330278
V1o9Jo/translation: 7b212f1c2fdc370974f859b08349b0c6
It4%2FFS/message: ae1527c2765592f13980acb66997e570
It4%2FFS/origin/0/0: 0ad2af981579a62b084b3d41462c5546
It4%2FFS/translation: ae1527c2765592f13980acb66997e570
@@ -2496,13 +2738,21 @@ checksums:
7Ufoyg/message: 5c418701b27b9acbc5dd199719007362
7Ufoyg/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
7Ufoyg/translation: 5c418701b27b9acbc5dd199719007362
uXKtPH/translation: 8a95d730b360db6041001450aa751ab4
uXKtPH/message: 8a95d730b360db6041001450aa751ab4
uXKtPH/origin/0/0: 619b4be43b9080346366c4bf6ba2253d
mx8%2B1u/message: f2949ca2dc18b0063af92e60779abb65
mx8%2B1u/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
mx8%2B1u/origin/0/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/1/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/2/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/3/0: fadd5a5b6963ec5f261072f7e8d2140b
mx8%2B1u/origin/4/0: dbad0c8d7863cb41f5495264f82e7081
mx8%2B1u/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
mx8%2B1u/origin/3/0: dbad0c8d7863cb41f5495264f82e7081
mx8%2B1u/translation: f2949ca2dc18b0063af92e60779abb65
8AwlaR/message: acb1fdc14ed875815ae9f8a5254bc634
8AwlaR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
8AwlaR/translation: acb1fdc14ed875815ae9f8a5254bc634
i5yNAO/translation: 478092018a27a4e588cb5c70968ef11a
i5yNAO/message: 478092018a27a4e588cb5c70968ef11a
i5yNAO/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
Ws%2B3X%2B/message: 905b10eeb09fdc5ac7fc757e935ee757
Ws%2B3X%2B/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
Ws%2B3X%2B/translation: 905b10eeb09fdc5ac7fc757e935ee757
@@ -2536,13 +2786,15 @@ checksums:
zERArS/placeholders/0/0: 06f23520ebabfffed4999b5bcf61c9cd
zERArS/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
zERArS/translation: aca196d729401d8adc5c8ca9d558840e
kwkhPe/message: 63c3b52882e0d779859307d672c178c2
kwkhPe/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
kwkhPe/origin/1/0: 15ea2dfd5eae4e05b226b1320103476e
kwkhPe/translation: 63c3b52882e0d779859307d672c178c2
IelE1h/message: 972773025e763ddf53273df6d37a729a
IelE1h/origin/0/0: 94d3e20c29bb517dd394711d7524e478
IelE1h/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
IelE1h/origin/2/0: 5f6c4a0c9be5098099167871ae1564af
IelE1h/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
IelE1h/translation: 972773025e763ddf53273df6d37a729a
cWtfn1/message: 9a91ea70f5eb55aac96640bf0fd7feff
cWtfn1/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
cWtfn1/origin/0/0: 69d8634b177fdf937764b406fd477547
cWtfn1/translation: 9a91ea70f5eb55aac96640bf0fd7feff
b3Thhd/message: 5b7b755f05330164dbfb00096c500ee3
b3Thhd/origin/0/0: 12c1fab543c8848bcb10107d47336736
@@ -2612,12 +2864,22 @@ checksums:
"%2BEkBs0/message": 69d1d2575f2e73b08ac622bde0a45b4d
"%2BEkBs0/origin/0/0": d1e1d7ccce60e05570bedfbb93fc9416
"%2BEkBs0/translation": 69d1d2575f2e73b08ac622bde0a45b4d
7sdSkl/message: 6132de0281f288a30690bb2218089951
7sdSkl/placeholders/magicLinkRecipient/0: 63ab7807b81db57469a3b7fa40e6826e
7sdSkl/origin/0/0: 055278d1067f7350adbbc1eaf446e43c
7sdSkl/translation: 6132de0281f288a30690bb2218089951
9y1RcT/message: 2dfa121e0ff1914e08068308a81973c0
9y1RcT/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
9y1RcT/translation: 2dfa121e0ff1914e08068308a81973c0
an6ayw/message: 2d39f6440ad379e417193dbfaa265b5f
an6ayw/origin/0/0: f64cd62b620f454998523af2a4330278
an6ayw/translation: 2d39f6440ad379e417193dbfaa265b5f
GdWB%2BV/message: 6a15a9b926ecd2c91454e9da19c6049e
GdWB%2BV/origin/0/0: f64cd62b620f454998523af2a4330278
GdWB%2BV/translation: 6a15a9b926ecd2c91454e9da19c6049e
LnaC5y/message: 229d2a9159e17f243cf317b9bd7d471c
LnaC5y/origin/0/0: a1fde3fcce608870830f168f027727ff
LnaC5y/translation: 229d2a9159e17f243cf317b9bd7d471c
2X4ecw/message: fcefd247ec76a372002d2cffac3c5b0f
2X4ecw/origin/0/0: a1fde3fcce608870830f168f027727ff
2X4ecw/translation: fcefd247ec76a372002d2cffac3c5b0f
@@ -2631,6 +2893,9 @@ checksums:
8iP9oQ/origin/0/0: f64cd62b620f454998523af2a4330278
8iP9oQ/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
8iP9oQ/translation: 5c07446c838d07572c6b40621507e951
kxZ7LS/message: 41ebcdff41ce85329915754faf7140c0
kxZ7LS/origin/0/0: f64cd62b620f454998523af2a4330278
kxZ7LS/translation: 41ebcdff41ce85329915754faf7140c0
3d54Wj/message: d67dc42810694fd1344a9a9545aca1e7
3d54Wj/origin/0/0: f64cd62b620f454998523af2a4330278
3d54Wj/translation: d67dc42810694fd1344a9a9545aca1e7
@@ -2674,7 +2939,8 @@ checksums:
UlhdTP/origin/0/0: 83911e3eacbad4583e2b1647a784d154
UlhdTP/translation: bc5c267ce3f04de94ff1379cf2ca48a3
chyf4J/message: 7159d620b7ef5c5afef734989a5be08b
chyf4J/origin/0/0: 5f6c4a0c9be5098099167871ae1564af
chyf4J/origin/0/0: 69d8634b177fdf937764b406fd477547
chyf4J/origin/1/0: 5f6c4a0c9be5098099167871ae1564af
chyf4J/translation: 7159d620b7ef5c5afef734989a5be08b
Ax%2FhYa/message: d0e83b9a0304d7c36cf8c85d98783ce2
Ax%2FhYa/origin/0/0: 33bf266228f835e12c0bfb0e5efb3be9
@@ -2692,7 +2958,8 @@ checksums:
wtxjAY/translation: 1857292f9567c556c781a23bd7e10544
CozWO1/message: 855614bbf862fd6a9f6a3e76943f92d4
CozWO1/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
CozWO1/origin/1/0: 5f6c4a0c9be5098099167871ae1564af
CozWO1/origin/1/0: 69d8634b177fdf937764b406fd477547
CozWO1/origin/2/0: 5f6c4a0c9be5098099167871ae1564af
CozWO1/translation: 855614bbf862fd6a9f6a3e76943f92d4
ghSJ4U/message: 681f86c9ca31699fc5a3ca3790e5972c
ghSJ4U/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
@@ -2735,6 +3002,9 @@ checksums:
RtlIYB/message: df4783d593505766d78c5bc08d3d48ac
RtlIYB/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
RtlIYB/translation: df4783d593505766d78c5bc08d3d48ac
3WXdoZ/message: 4cbeab40f77975492a23eec428732440
3WXdoZ/origin/0/0: 69d8634b177fdf937764b406fd477547
3WXdoZ/translation: 4cbeab40f77975492a23eec428732440
aelko%2B/message: 41dd145ef56f539e12dc064a9807c660
aelko%2B/origin/0/0: 4980d8e27a628e7cd9a70b839d844083
aelko%2B/translation: 41dd145ef56f539e12dc064a9807c660
@@ -2769,15 +3039,24 @@ checksums:
MdN5zD/message: e2e816f2b7a13b1056d79e7f25088664
MdN5zD/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
MdN5zD/translation: e2e816f2b7a13b1056d79e7f25088664
ZhQoZn/message: b05a1191bf048d76e7d6c1c3e76ce1fd
ZhQoZn/origin/0/0: 0627e0040ca4939c9a57349e98c51797
ZhQoZn/translation: b05a1191bf048d76e7d6c1c3e76ce1fd
2SePRT/message: 257b840726f972f384243a72767f880f
2SePRT/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
2SePRT/translation: 257b840726f972f384243a72767f880f
uOqaMC/message: 24fc6cdc8740f37a83df85f582f03293
uOqaMC/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
uOqaMC/translation: 24fc6cdc8740f37a83df85f582f03293
8Zjc7q/message: 65609a209e74f230563a7c8d7258a83c
8Zjc7q/origin/0/0: 278a40eb45b1219f424703d0abc1d273
8Zjc7q/translation: 65609a209e74f230563a7c8d7258a83c
GoDLB9/message: 8c8d944e07388c5877effdb2c2803dcf
GoDLB9/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
GoDLB9/translation: 8c8d944e07388c5877effdb2c2803dcf
c4nMcR/message: bf4f697079eef51c3ebe46ce79063895
c4nMcR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
c4nMcR/translation: bf4f697079eef51c3ebe46ce79063895
evg7%2BA/message: 403972e7a25afc2415762c1c2b1ec868
evg7%2BA/origin/0/0: 3c57a918258af4b44c187f58d203345f
evg7%2BA/translation: 403972e7a25afc2415762c1c2b1ec868
@@ -2796,9 +3075,18 @@ checksums:
PELbXB/message: 6984aed03bbcd94041c5dd20f62e56ed
PELbXB/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
PELbXB/translation: 6984aed03bbcd94041c5dd20f62e56ed
ygpeZ8/message: 59444b15ae58f987931a4746f4c77a0b
ygpeZ8/origin/0/0: 278a40eb45b1219f424703d0abc1d273
ygpeZ8/translation: 59444b15ae58f987931a4746f4c77a0b
"%2BA6Wuq/message": 36d33d3622921dbb1475da2acb98f754
"%2BA6Wuq/origin/0/0": 278a40eb45b1219f424703d0abc1d273
"%2BA6Wuq/translation": 36d33d3622921dbb1475da2acb98f754
Ozt2vv/message: 8f730f68d52b7efa727aa0784282ebcb
Ozt2vv/origin/0/0: 0627e0040ca4939c9a57349e98c51797
Ozt2vv/translation: 8f730f68d52b7efa727aa0784282ebcb
EuK5ez/message: d262a1ba9ebeea549d982f9cf85a6c18
EuK5ez/origin/0/0: 0627e0040ca4939c9a57349e98c51797
EuK5ez/translation: d262a1ba9ebeea549d982f9cf85a6c18
"%2FtOdd2/message": 099e1780348cd7bf617721344371a430
"%2FtOdd2/origin/0/0": 03c00909b41be01231cd3e843c8ee44e
"%2FtOdd2/translation": 099e1780348cd7bf617721344371a430
@@ -2814,6 +3102,12 @@ checksums:
25fAUC/message: 3e4cce96bc1197a1caa2b7fb4d3c7e53
25fAUC/origin/0/0: d1e1d7ccce60e05570bedfbb93fc9416
25fAUC/translation: 3e4cce96bc1197a1caa2b7fb4d3c7e53
GRAGsB/message: 751a534fd6e62a92ff64c46894c7db86
GRAGsB/origin/0/0: 69d8634b177fdf937764b406fd477547
GRAGsB/translation: 751a534fd6e62a92ff64c46894c7db86
j0o6ml/message: 1e036ffca9d9cb9e2941b83fbbf375c0
j0o6ml/origin/0/0: 69d8634b177fdf937764b406fd477547
j0o6ml/translation: 1e036ffca9d9cb9e2941b83fbbf375c0
GnSSGm/message: 98de6f65c567c6654cc0adce3c9d5656
GnSSGm/origin/0/0: 33bf266228f835e12c0bfb0e5efb3be9
GnSSGm/translation: 98de6f65c567c6654cc0adce3c9d5656
@@ -2826,6 +3120,10 @@ checksums:
Hg1qE9/message: c808949b9b2b4a9aba2472f5d1050167
Hg1qE9/origin/0/0: 30791e26a172d47d3cb0693e82679246
Hg1qE9/translation: c808949b9b2b4a9aba2472f5d1050167
OTxjpR/message: 370a32c49489db61978ac91b55699c24
OTxjpR/origin/0/0: 69d8634b177fdf937764b406fd477547
OTxjpR/origin/1/0: 69d8634b177fdf937764b406fd477547
OTxjpR/translation: 370a32c49489db61978ac91b55699c24
4DOCMI/message: 0b48896061a1124501fdaba026804148
4DOCMI/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
4DOCMI/translation: 0b48896061a1124501fdaba026804148

View File

@@ -46,6 +46,7 @@ interface FormValues {
interface AuthProps {
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
isSignUp?: boolean;
callbackURL?: string;
}
const EmailSchema = z.object({
@@ -152,7 +153,11 @@ const availableSocialProviders = {
},
};
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
export function Auth({
setIsMagicLinkSent,
isSignUp,
callbackURL: callbackURLProp,
}: AuthProps) {
const [isCloudEnv, setIsCloudEnv] = useState(false);
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
useState<null | AuthProvider>(null);
@@ -165,7 +170,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
const passwordRef = useRef<HTMLInputElement | null>(null);
const redirect = useSearchParams().get("next");
const callbackURL = redirect ?? "/boards";
const callbackURL = callbackURLProp ?? redirect ?? "/boards";
// Safely get environment variables on client side to avoid hydration mismatch
useEffect(() => {

View File

@@ -33,6 +33,7 @@ type SearchResult =
boardPublicId: string;
boardName: string;
listName: string;
cardNumber: number | null;
updatedAt: Date | null;
createdAt: Date;
type: "card";
@@ -159,8 +160,17 @@ export default function CommandPallette({
)}
</div>
<div className="min-w-0 flex-1 text-left">
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
{result.title}
<div className="flex items-center gap-2">
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
{result.title}
</div>
{result.type === "card" &&
result.cardNumber != null &&
workspace.cardPrefix && (
<span className="flex-shrink-0 text-xs text-light-600 dark:text-dark-600">
{workspace.cardPrefix}-{result.cardNumber}
</span>
)}
</div>
{result.type === "card" && (
<div className="truncate text-xs text-light-700 dark:text-dark-700">

View File

@@ -1,3 +1,6 @@
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useTheme } from "next-themes";
import { useEffect, useRef, useState } from "react";
import {
@@ -11,8 +14,12 @@ import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
import { api } from "~/utils/api";
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
import Button from "./Button";
import Modal from "./modal";
import SideNavigation from "./SideNavigation";
interface DashboardProps {
@@ -41,8 +48,11 @@ export default function Dashboard({
hasRightPanel = false,
}: DashboardProps) {
const { resolvedTheme } = useTheme();
const { openModal } = useModal();
const { openModal, closeModal, modalContentType } = useModal();
const { availableWorkspaces, hasLoaded } = useWorkspace();
const { showPopup } = usePopup();
const router = useRouter();
const searchParams = useSearchParams();
const { data: session, isPending: sessionLoading } = authClient.useSession();
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
@@ -97,10 +107,65 @@ export default function Dashboard({
});
useEffect(() => {
if (hasLoaded && availableWorkspaces.length === 0) {
openModal("NEW_WORKSPACE", undefined, undefined, false);
const partnerActivated = searchParams.get("partner_activated");
const partnerError = searchParams.get("partner_error");
if (partnerActivated) {
showPopup({
header: t`License activated`,
message: t`Your license has been activated successfully.`,
icon: "success",
});
const params = new URLSearchParams(searchParams.toString());
params.delete("partner_activated");
router.replace(`?${params.toString()}`);
} else if (partnerError) {
const messages: Record<string, string> = {
invalid_license: t`That license key could not be found. Please contact support.`,
license_inactive: t`Your license is not active. Please check your account.`,
missing_license: t`No license key was provided. Please try activating again.`,
};
showPopup({
header: t`License activation failed`,
message:
messages[partnerError] ??
t`Something went wrong during license activation.`,
icon: "error",
});
const params = new URLSearchParams(searchParams.toString());
params.delete("partner_error");
router.replace(`?${params.toString()}`);
}
}, [hasLoaded, availableWorkspaces.length, openModal]);
}, [searchParams, showPopup, router]);
useEffect(() => {
if (hasLoaded && availableWorkspaces.length === 0) {
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
router.push(
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
} else {
openModal("NEW_WORKSPACE", undefined, undefined, false);
}
}
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
useEffect(() => {
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
if (
!userLoading &&
user &&
isCredentialsEnabled &&
user.hasMagicLinkAccount &&
!user.hasPassword &&
!sessionStorage.getItem("set_password_prompted")
) {
sessionStorage.setItem("set_password_prompted", "1");
openModal("SET_PASSWORD_PROMPT");
}
}, [user, userLoading, openModal]);
const isDarkMode = resolvedTheme === "dark";
@@ -196,6 +261,30 @@ export default function Dashboard({
</div>
</div>
</div>
<Modal
modalSize="sm"
isVisible={modalContentType === "SET_PASSWORD_PROMPT"}
>
{user?.hasPassword ? (
<div className="p-5">
<h2 className="pb-4 text-base font-medium dark:text-white">{t`Password already set`}</h2>
<p className="mb-6 text-sm text-light-900">
{t`Your account already has a password. You can change it from your account settings.`}
</p>
<Button
variant="secondary"
onClick={closeModal}
fullWidth
size="lg"
>
{t`Close`}
</Button>
</div>
) : (
<ChangePasswordFormConfirmation hasPassword={false} />
)}
</Modal>
</>
);
}

View File

@@ -85,7 +85,7 @@ export interface RenderSuggestionsProps {
export interface WorkspaceMember {
publicId: string;
user: {
id: string;
id: string | null;
name: string | null;
image: string | null;
} | null;
@@ -474,7 +474,7 @@ export default function Editor({
autolink: true,
linkOnPaste: true,
}),
Markdown,
Markdown.configure({ transformPastedText: true }),
Placeholder.configure({
placeholder: readOnly
? ""

View File

@@ -88,7 +88,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
</button>
)}
{iconRight && type !== "password" && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="absolute right-3 top-1/2 -translate-y-1/2 leading-[0]">
{iconRight}
</div>
)}

View File

@@ -39,7 +39,7 @@ export function LabelForm({
labelPublicId: entityId,
},
{
enabled: isEdit && !!entityId,
enabled: !!isEdit && entityId.length >= 12,
},
);
@@ -207,7 +207,7 @@ export function LabelForm({
/>
</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 space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{!isEdit && (
<Toggle
label={t`Create another`}

View File

@@ -1,21 +1,14 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import {
HiBolt,
HiCheck,
HiCheckBadge,
HiInformationCircle,
HiXMark,
} from "react-icons/hi2";
import { HiCheck, HiXMark } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useDebounce } from "~/hooks/useDebounce";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -54,7 +47,6 @@ export function NewWorkspaceForm() {
watch,
trigger,
clearErrors,
setValue,
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
@@ -67,16 +59,10 @@ export function NewWorkspaceForm() {
const hasAvailableWorkspaces = availableWorkspaces.length > 0;
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
const slug = watch("slug");
const [debouncedSlug] = useDebounce(slug, 500);
const isTyping = slug !== debouncedSlug;
// Pro toggle state management
const [isProToggleEnabled, setIsProToggleEnabled] = useState(false);
const [lastAvailableSlug, setLastAvailableSlug] = useState<string>("");
// Validate slug only after debounce
useEffect(() => {
if (isTyping) {
@@ -101,7 +87,7 @@ export function NewWorkspaceForm() {
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
const createWorkspace = api.workspace.create.useMutation({
onSuccess: async (values, variables) => {
onSuccess: (values) => {
if (values.publicId && values.name) {
void utils.workspace.all.invalidate();
switchWorkspace({
@@ -110,46 +96,11 @@ export function NewWorkspaceForm() {
description: values.description,
slug: values.slug,
plan: values.plan,
cardPrefix: values.cardPrefix,
role: "admin",
weekStartDay: 1,
});
// If in cloud and Pro toggle is enabled, create checkout session for pro
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isProToggleEnabled) {
try {
const response = await fetch(
"/api/stripe/create_checkout_session",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
slug: slug || undefined,
workspacePublicId: values.publicId,
cancelUrl: "/settings/workspace?upgrade=pro",
successUrl: "/boards",
}),
},
);
const data = await response.json();
const url = (data as { url: string }).url;
if (url) {
window.location.href = url;
return; // Don't close modal if redirecting to checkout
}
} catch (error) {
console.error("Error creating checkout session:", error);
showPopup({
header: t`Error upgrading to Pro`,
message: t`Workspace created successfully. You can upgrade later in settings.`,
icon: "warning",
});
}
}
closeModal();
}
},
@@ -168,57 +119,8 @@ export function NewWorkspaceForm() {
if (nameElement) nameElement.focus();
}, []);
const [shouldShowBenefits, setShouldShowBenefits] = useState(false);
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
useEffect(() => {
if (
isCloudEnv &&
!checkWorkspaceSlugAvailability.isPending &&
isValidSlug
) {
const isAvailable = isWorkspaceSlugAvailable?.isAvailable === true;
setShouldShowBenefits(isAvailable);
// Automatically enable Pro toggle when slug is available
if (isAvailable) {
setIsProToggleEnabled(true);
setLastAvailableSlug(slug);
}
}
}, [
isValidSlug,
isWorkspaceSlugAvailable?.isAvailable,
checkWorkspaceSlugAvailability.isPending,
slug,
isCloudEnv,
]);
// Reset benefits when slug becomes invalid
useEffect(() => {
if (!isValidSlug) {
setShouldShowBenefits(false);
}
}, [isValidSlug]);
// Handle Pro toggle changes
const handleProToggleChange = () => {
if (isProToggleEnabled) {
// Turning off: clear the slug and disable Pro
setValue("slug", "");
setIsProToggleEnabled(false);
} else {
// Turning on: restore the last available slug if we have one
if (lastAvailableSlug) {
setValue("slug", lastAvailableSlug);
}
setIsProToggleEnabled(true);
}
};
const showProBenefits = isProToggleEnabled;
const onSubmit = (values: FormValues) => {
// Don't submit if slug is provided but not available
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
@@ -227,7 +129,7 @@ export function NewWorkspaceForm() {
createWorkspace.mutate({
name: values.name,
slug: !isCloudEnv && values.slug ? values.slug : undefined,
slug: values.slug,
});
};
@@ -290,11 +192,7 @@ export function NewWorkspaceForm() {
? t`This workspace URL is reserved`
: undefined)
}
prefix={
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? "kan.bn/"
: `${env("NEXT_PUBLIC_BASE_URL")}/`
}
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/`}
iconRight={
slug && slug.length >= 3 && !errors.slug ? (
isWorkspaceSlugAvailable?.isAvailable ? (
@@ -311,68 +209,9 @@ export function NewWorkspaceForm() {
}
}}
/>
{slug && slug.length >= 3 && shouldShowBenefits && (
<div className="mt-2 flex items-center gap-1">
<HiInformationCircle className="h-4 w-4 text-dark-900" />
<p className="text-xs text-gray-500 dark:text-dark-900">
{t`Custom URLs require upgrading to a Pro plan`}
</p>
</div>
)}
</div>
{showProBenefits && (
<div className="mt-6">
<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-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
<div className="flex items-center space-x-2">
<span className="text-xs 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-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
<span className="text-xs text-neutral-900 dark:text-dark-1000">
{t`Custom workspace URL`}
</span>
</div>
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-900 dark:text-dark-1000">
{t`Board analytics`}
</span>
<span className="inline-flex items-center rounded-full bg-gray-500/10 px-2 py-0.5 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/20 dark:text-gray-400 sm:text-[10px]">
{t`Coming soon`}
</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
<div
className={twMerge(
"mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600",
!showProBenefits && "mt-12",
)}
>
{/* Pro Toggle - only show in cloud environment */}
{isCloudEnv && (
<Toggle
isChecked={isProToggleEnabled}
onChange={handleProToggleChange}
label={t`Upgrade to Pro ($29/month)`}
/>
)}
<div className="mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button
type="submit"

View File

@@ -28,7 +28,6 @@ import ButtonComponent from "~/components/Button";
import ReactiveButton from "~/components/ReactiveButton";
import UserMenu from "~/components/UserMenu";
import WorkspaceMenu from "~/components/WorkspaceMenu";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
@@ -53,7 +52,6 @@ export default function SideNavigation({
const { workspace } = useWorkspace();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isInitialised, setIsInitialised] = useState(false);
const { openModal } = useModal();
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
@@ -223,21 +221,19 @@ export default function SideNavigation({
<ButtonComponent
iconLeft={<HiBolt />}
variant="secondary"
href="/settings/workspace?upgrade=pro"
aria-label="Upgrade to Pro"
title="Upgrade to Pro"
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
aria-label={t`Start free trial`}
title={t`Start free trial`}
iconOnly
onClick={() => openModal("UPGRADE_TO_PRO")}
/>
) : (
<ButtonComponent
iconLeft={<HiBolt />}
fullWidth
variant="secondary"
href="/settings/workspace?upgrade=pro"
onClick={() => openModal("UPGRADE_TO_PRO")}
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
>
{t`Upgrade to Pro`}
{t`Start free trial`}
</ButtonComponent>
)}
</div>

View File

@@ -7,15 +7,17 @@ const Toggle = ({
label,
disabled,
showLabel = true,
labelPosition = "before",
}: {
isChecked: boolean;
onChange: () => void;
label: string;
disabled?: boolean;
showLabel?: boolean;
labelPosition?: "before" | "after";
}) => (
<div className="mr-4 flex items-center justify-end">
{showLabel && (
<div className="flex items-center">
{showLabel && labelPosition === "before" && (
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
@@ -38,6 +40,11 @@ const Toggle = ({
)}
/>
</Switch>
{showLabel && labelPosition === "after" && (
<span className="ml-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
)}
</div>
);

View File

@@ -1,5 +1,7 @@
import { Button, Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { Fragment, useState } from "react";
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
@@ -18,6 +20,7 @@ export default function WorkspaceMenu({
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace();
const { openModal } = useModal();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const { tooltipContent: commandPaletteShortcutTooltipContent } =
@@ -147,7 +150,11 @@ export default function WorkspaceMenu({
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
<Menu.Item>
<button
onClick={() => openModal("NEW_WORKSPACE")}
onClick={() =>
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`)
: openModal("NEW_WORKSPACE")
}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
{t`Create workspace`}

View File

@@ -1,6 +1,6 @@
import type { Locale as DateFnsLocale } from "date-fns";
import { useLingui } from "@lingui/react";
import { de, enGB, es, fr, it, nl, pl, ru, ptBR} from "date-fns/locale";
import { de, enGB, es, fr, it, nl, pl, ptBR, ru } from "date-fns/locale";
import type { Locale } from "~/locales";
import { useLinguiContext } from "~/providers/lingui";
@@ -19,7 +19,7 @@ export function useLocalisation() {
nl,
ru,
pl,
"pt-BR": ptBR,
ptbr: ptBR,
};
const currentDateLocale = dateLocaleMap[locale] ?? enGB;

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,13 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { env } from "~/env";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
@@ -32,7 +33,9 @@ export default withRateLimit(
try {
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
} catch {
return res.status(500).json({ message: "Storage endpoint misconfigured" });
return res
.status(500)
.json({ message: "Storage endpoint misconfigured" });
}
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
@@ -66,10 +69,7 @@ export default withRateLimit(
const buffer = await upstream.arrayBuffer();
return res.send(Buffer.from(buffer));
} catch (error) {
console.error("Error downloading attachment:", error);
return res
.status(500)
.json({ message: "Failed to download attachment" });
return res.status(500).json({ message: "Failed to download attachment" });
}
},
}),
);

View File

@@ -1,25 +1,25 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
try {
const response = await fetch("https://formbricks.com/api/oss-friends");
if (!response.ok) {
throw new Error("Failed to fetch from Formbricks");
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const data = await response.json();
return res.status(200).json(data);
} catch (error) {
console.error("Error fetching OSS friends:", error);
return res.status(500).json({ message: "Failed to fetch OSS friends" });
}
},
try {
const response = await fetch("https://formbricks.com/api/oss-friends");
if (!response.ok) {
throw new Error("Failed to fetch from Formbricks");
}
const data = await response.json();
return res.status(200).json(data);
} catch (error) {
return res.status(500).json({ message: "Failed to fetch OSS friends" });
}
}),
);

View File

@@ -0,0 +1,19 @@
export type WorkspacePlan = "free" | "team" | "pro" | "enterprise";
export interface TierConfig {
plan: WorkspacePlan;
seats: number | null;
unlimitedSeats: boolean;
}
const TIER_MAP: Record<number, TierConfig> = {
1: { plan: "team", seats: 5, unlimitedSeats: false },
2: { plan: "pro", seats: 15, unlimitedSeats: false },
3: { plan: "pro", seats: null, unlimitedSeats: true },
};
export function tierConfig(tier: number): TierConfig {
const config = TIER_MAP[tier];
if (!config) throw new Error(`Unknown partner tier: ${tier}`);
return config;
}

View File

@@ -0,0 +1,168 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { tierConfig } from "./_utils";
const log = createLogger("api");
interface TokenResponse {
access_token: string;
token_type: string;
}
interface OAuthLicenseResponse {
license_key: string;
status: string;
}
interface LicenseDetailResponse {
license_key: string;
status: string;
tier: number;
}
async function exchangeCodeForToken(code: string): Promise<TokenResponse> {
const tokenUrl = process.env.PARTNER_TOKEN_URL;
const clientId = process.env.PARTNER_CLIENT_ID;
const clientSecret = process.env.PARTNER_CLIENT_SECRET;
const redirectUrl = process.env.PARTNER_REDIRECT_URL;
if (!tokenUrl) throw new Error("PARTNER_TOKEN_URL not configured");
if (!clientId) throw new Error("PARTNER_CLIENT_ID not configured");
if (!clientSecret) throw new Error("PARTNER_CLIENT_SECRET not configured");
if (!redirectUrl) throw new Error("PARTNER_REDIRECT_URL not configured");
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUrl,
}),
});
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
return res.json() as Promise<TokenResponse>;
}
async function fetchOAuthLicense(
accessToken: string,
): Promise<OAuthLicenseResponse> {
const oauthLicenseUrl = process.env.PARTNER_OAUTH_LICENSE_URL;
if (!oauthLicenseUrl)
throw new Error("PARTNER_OAUTH_LICENSE_URL not configured");
const res = await fetch(`${oauthLicenseUrl}?access_token=${accessToken}`);
if (!res.ok) throw new Error(`OAuth license fetch failed: ${res.status}`);
return res.json() as Promise<OAuthLicenseResponse>;
}
async function fetchLicenseDetail(
licenseKey: string,
): Promise<LicenseDetailResponse> {
const licenseApiUrl = process.env.PARTNER_LICENSE_API_URL;
const apiKey = process.env.PARTNER_API_KEY;
if (!licenseApiUrl) throw new Error("PARTNER_LICENSE_API_URL not configured");
if (!apiKey) throw new Error("PARTNER_API_KEY not configured");
const apiKeyHeader = process.env.PARTNER_API_KEY_HEADER;
if (!apiKeyHeader) throw new Error("PARTNER_API_KEY_HEADER not configured");
const res = await fetch(`${licenseApiUrl}/${licenseKey}`, {
headers: { [apiKeyHeader]: apiKey },
});
if (!res.ok) throw new Error(`License detail fetch failed: ${res.status}`);
return res.json() as Promise<LicenseDetailResponse>;
}
export default withRateLimit(
{ points: 20, duration: 60 },
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const { code } = req.query;
if (!code || typeof code !== "string") {
return res.status(200).json({ message: "OK" });
}
let license: LicenseDetailResponse;
try {
const tokenData = await exchangeCodeForToken(code);
const oauthLicense = await fetchOAuthLicense(tokenData.access_token);
license = await fetchLicenseDetail(oauthLicense.license_key);
} catch (err) {
log.error({ err }, "Partner OAuth flow failed");
return res.redirect(`/partner/activate?error=oauth_failed`);
}
const { db, user } = await createNextApiContext(req);
const cfg = tierConfig(license.tier);
const isActive = license.status === "active";
const status = isActive ? "active" : "inactive";
if (!user) {
await subscriptionRepo.upsertByPartnerLicenseKey(
db,
license.license_key,
{
plan: cfg.plan,
status,
partnerTier: license.tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
);
return res.redirect(
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
);
}
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
const workspace = memberships?.[0]?.workspace;
if (!workspace) {
await subscriptionRepo.upsertByPartnerLicenseKey(
db,
license.license_key,
{
plan: cfg.plan,
status,
partnerTier: license.tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
);
return res.redirect(
`/onboarding/workspace?license_key=${encodeURIComponent(license.license_key)}`,
);
}
await subscriptionRepo.upsertByPartnerLicenseKey(db, license.license_key, {
plan: cfg.plan,
status,
partnerTier: license.tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
referenceId: workspace.publicId,
});
if (isActive) {
await workspaceRepo.update(db, workspace.publicId, { plan: cfg.plan });
}
return res.redirect(`/?partner_activated=1`);
}),
);

View File

@@ -0,0 +1,64 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
export default withRateLimit(
{ points: 20, duration: 60 },
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const { license_key } = req.query;
if (!license_key || typeof license_key !== "string") {
return res.redirect("/boards?partner_error=missing_license");
}
const { db, user } = await createNextApiContext(req);
if (!user) {
return res.redirect(
`/login?next=${encodeURIComponent(`/api/partner/link?license_key=${license_key}`)}`,
);
}
const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key);
if (!sub) {
return res.redirect("/boards?partner_error=invalid_license");
}
if (sub.status !== "active") {
return res.redirect("/boards?partner_error=license_inactive");
}
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
const workspace = memberships?.[0]?.workspace;
if (!workspace) {
return res.redirect(
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
);
}
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: sub.plan,
status: sub.status,
partnerTier: sub.partnerTier ?? 1,
seats: sub.seats ?? null,
unlimitedSeats: sub.unlimitedSeats,
referenceId: workspace.publicId,
});
await workspaceRepo.update(db, workspace.publicId, {
plan: sub.plan as "free" | "team" | "pro" | "enterprise",
});
return res.redirect("/boards?partner_activated=1");
}),
);

View File

@@ -0,0 +1,212 @@
import { createHmac, timingSafeEqual } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import type { Readable } from "node:stream";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { getActiveSubscriptions } from "@kan/shared/utils";
import { tierConfig } from "./_utils";
const log = createLogger("api");
async function buffer(readable: Readable) {
const chunks: Buffer[] = [];
for await (const chunk of readable) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}
function verifySignature(
rawBody: string,
signature: string,
timestamp: string,
): boolean {
const secret = process.env.PARTNER_API_KEY;
if (!secret) return false;
const ts = Number(timestamp);
if (isNaN(ts) || Date.now() - ts > 300_000) return false;
const payload = `${timestamp}${rawBody}`;
const expected = createHmac("sha256", secret).update(payload).digest("hex");
try {
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
} catch {
return false;
}
}
interface WebhookPayload {
event:
| "purchase"
| "activate"
| "deactivate"
| "upgrade"
| "downgrade"
| "migrate";
license_key: string;
license_status: string;
tier: number;
prev_license_key?: string;
}
export default withApiLogging(
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const signatureHeader = process.env.PARTNER_SIGNATURE_HEADER;
const timestampHeader = process.env.PARTNER_TIMESTAMP_HEADER;
if (!signatureHeader || !timestampHeader) {
log.error("Webhook signature headers not configured");
return res.status(500).json({ message: "Server misconfiguration" });
}
const signature = req.headers[signatureHeader] as string | undefined;
const timestamp = req.headers[timestampHeader] as string | undefined;
if (!signature || !timestamp) {
return res.status(400).json({ message: "Missing signature headers" });
}
const buf = await buffer(req as unknown as Readable);
const rawBody = buf.toString("utf8");
if (!verifySignature(rawBody, signature, timestamp)) {
return res.status(401).json({ message: "Invalid signature" });
}
const payload = JSON.parse(rawBody) as WebhookPayload;
const { event, license_key, license_status, tier, prev_license_key } =
payload;
log.info({ headers: req.headers, payload }, "partner webhook received");
const { db } = await createNextApiContext(req);
switch (event) {
case "purchase": {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: license_status,
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
break;
}
case "activate": {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
break;
}
case "deactivate": {
const sub = await subscriptionRepo.getByPartnerLicenseKey(
db,
license_key,
);
if (sub) {
const [, allSubs] = await Promise.all([
subscriptionRepo.updateById(db, sub.id, {
plan: "free",
status: "inactive",
}),
sub.referenceId
? subscriptionRepo.getByReferenceId(db, sub.referenceId)
: Promise.resolve([]),
]);
if (sub.referenceId) {
const hasActiveSub = getActiveSubscriptions(allSubs).some(
(s) => s.id !== sub.id,
);
if (!hasActiveSub) {
await workspaceRepo.update(db, sub.referenceId, { plan: "free" });
}
}
}
break;
}
case "upgrade":
case "downgrade": {
const lookupKey = prev_license_key ?? license_key;
const sub = await subscriptionRepo.getByPartnerLicenseKey(
db,
lookupKey,
);
if (sub) {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
referenceId: sub.referenceId ?? undefined,
});
if (prev_license_key) {
await subscriptionRepo.updateById(db, sub.id, {
status: "inactive",
});
}
if (sub.referenceId) {
await workspaceRepo.update(db, sub.referenceId, { plan: cfg.plan });
}
}
break;
}
case "migrate": {
if (prev_license_key) {
const sub = await subscriptionRepo.getByPartnerLicenseKey(
db,
prev_license_key,
);
if (sub) {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
referenceId: sub.referenceId ?? undefined,
});
await subscriptionRepo.updateById(db, sub.id, {
status: "inactive",
});
}
}
break;
}
default:
log.warn({ event }, "Unhandled partner webhook event");
}
return res.status(200).json({ success: true, event });
},
);
export const config = {
api: {
bodyParser: false,
},
};

View File

@@ -2,34 +2,34 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { env } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
import { createStripeClient } from "@kan/stripe";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createStripeClient } from "@kan/stripe";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user } = await createNextApiContext(req);
if (!user?.stripeCustomerId) {
return res.status(404).json({ error: "No billing account found" });
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
});
try {
const { user } = await createNextApiContext(req);
return res.status(200).json({ url: session.url });
} catch (error) {
console.error("Error:", error);
return res.status(500).json({ error: "Error creating portal session" });
}
},
if (!user?.stripeCustomerId) {
return res.status(404).json({ error: "No billing account found" });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
});
return res.status(200).json({ url: session.url });
} catch (error) {
return res.status(500).json({ error: "Error creating portal session" });
}
}),
);

View File

@@ -3,10 +3,13 @@ import { env } from "next-runtime-env";
import { z } from "zod";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { assertPermission } from "@kan/api/utils/permissions";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateUID } from "@kan/shared/utils";
import { createStripeClient } from "@kan/stripe";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const workspaceSlugSchema = z
.string()
@@ -17,21 +20,24 @@ const workspaceSlugSchema = z
interface CheckoutSessionRequest {
successUrl: string;
cancelUrl: string;
slug: string;
workspacePublicId: string;
stripeCustomerId: string;
plan: "team" | "pro";
billing?: string;
workspacePublicId?: string;
slug?: string;
workspaceName?: string;
workspaceDescription?: string;
workspaceSlug?: string;
}
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user, db } = await createNextApiContext(req);
if (!user) {
@@ -39,81 +45,107 @@ export default withRateLimit(
}
const body = req.body as CheckoutSessionRequest;
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
const {
successUrl,
cancelUrl,
plan,
billing,
workspacePublicId,
slug,
workspaceName,
workspaceDescription,
workspaceSlug,
} = body;
if (!successUrl || !cancelUrl || !workspacePublicId) {
if (!successUrl || !cancelUrl || !plan) {
return res.status(400).json({ error: "Missing required fields" });
}
if (slug) {
const slugResult = workspaceSlugSchema.safeParse(slug);
if (!workspacePublicId && !workspaceName) {
return res
.status(400)
.json({ error: "Must provide workspacePublicId or workspaceName" });
}
const resolvedSlug = slug ?? workspaceSlug;
if (resolvedSlug) {
const slugResult = workspaceSlugSchema.safeParse(resolvedSlug);
if (!slugResult.success) {
return new Response(
JSON.stringify({ error: "Invalid workspace slug" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
return res.status(400).json({ error: "Invalid workspace slug" });
}
}
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
let resolvedWorkspacePublicId = workspacePublicId;
let subscriptionId: number | undefined;
const isMemberOfWorkspace = workspace.some(
({ workspace }) => workspace.publicId === body.workspacePublicId,
);
if (workspacePublicId) {
// Existing workspace upgrade
const workspace = await workspaceRepo.getByPublicId(
db,
workspacePublicId,
);
if (!isMemberOfWorkspace) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 403,
headers: { "Content-Type": "application/json" },
if (!workspace) {
return res.status(404).json({ error: "Workspace not found" });
}
try {
await assertPermission(db, user.id, workspace.id, "workspace:manage");
} catch {
return res.status(403).json({ error: "Unauthorized" });
}
const subscription = await subscriptionRepo.create(db, {
plan: plan,
referenceId: workspacePublicId,
userId: user.id,
stripeCustomerId: user.stripeCustomerId ?? "",
status: "incomplete",
});
subscriptionId = subscription?.id;
if (!subscriptionId) {
return res.status(500).json({ error: "Error creating subscription" });
}
} else {
resolvedWorkspacePublicId = generateUID();
}
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 isTeam = body.plan === "team";
const annualPriceId = isTeam
? (process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID ??
process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID)
: (process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID ??
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID);
const monthlyPriceId = isTeam
? process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID;
const priceId = billing === "annual" ? annualPriceId : monthlyPriceId;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_collection: "always",
line_items: [
{
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
quantity: 1,
},
],
subscription_data: {
trial_period_days: 14,
},
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: { trial_period_days: 14 },
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}?workspacePublicId=${resolvedWorkspacePublicId}`,
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
client_reference_id: workspacePublicId,
client_reference_id: resolvedWorkspacePublicId,
customer: user.stripeCustomerId ?? undefined,
metadata: {
...(slug && { workspaceSlug: slug }),
workspacePublicId,
workspacePublicId: resolvedWorkspacePublicId!,
userId: user.id,
subscriptionId,
userEmail: user.email ?? "",
...(resolvedSlug && { workspaceSlug: resolvedSlug }),
...(workspaceName && { workspaceName }),
...(workspaceDescription && { workspaceDescription }),
...(subscriptionId && { subscriptionId: String(subscriptionId) }),
plan: plan,
isNewWorkspace: workspaceName ? "true" : "false",
},
});
return res.status(200).json({ url: session.url });
} catch (error) {
console.error("Error:", error);
return res.status(500).json({ error: "Error creating checkout session" });
}
},
}),
);

View File

@@ -1,12 +1,14 @@
import { randomUUID } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import type { Readable } from "node:stream";
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 { createLogger } from "@kan/logger";
import { createStripeClient } from "@kan/stripe";
const log = createLogger("stripe-webhook");
const log = createLogger("api");
async function buffer(readable: Readable) {
const chunks = [];
@@ -21,6 +23,9 @@ export default async function handler(
res: NextApiResponse,
) {
const stripe = createStripeClient();
const start = Date.now();
const requestId = randomUUID();
const procedure = req.url?.split("?")[0] ?? "/api/stripe/webhook";
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
@@ -44,29 +49,88 @@ export default async function handler(
const { db } = await createNextApiContext(req);
log.info({ eventType: event.type, eventId: event.id }, "Stripe webhook received");
switch (event.type) {
case "checkout.session.completed": {
const checkoutSession = event.data.object;
const meta = checkoutSession.metadata;
const metaData = checkoutSession.metadata;
if (!meta?.workspacePublicId) break;
if (metaData?.workspacePublicId) {
await workspaceRepo.update(db, metaData.workspacePublicId, {
...(metaData.workspaceSlug && { slug: metaData.workspaceSlug }),
plan: "pro",
const plan = meta.plan === "team" ? "team" : ("pro" as const);
if (
meta.isNewWorkspace === "true" &&
meta.workspaceName &&
meta.userId &&
meta.userEmail
) {
const existing = await workspaceRepo.getByPublicId(
db,
meta.workspacePublicId,
);
if (!existing) {
const slug = meta.workspaceSlug ?? meta.workspacePublicId;
await workspaceRepo.create(db, {
publicId: meta.workspacePublicId,
name: meta.workspaceName,
slug,
plan,
createdBy: meta.userId,
createdByEmail: meta.userEmail,
...(meta.workspaceDescription && {
description: meta.workspaceDescription,
}),
});
await subscriptionRepo.create(db, {
plan,
referenceId: meta.workspacePublicId,
userId: meta.userId,
stripeCustomerId: checkoutSession.customer as string,
status: "active",
});
}
} else {
await workspaceRepo.update(db, meta.workspacePublicId, {
plan,
...(plan === "pro" &&
meta.workspaceSlug && { slug: meta.workspaceSlug }),
});
}
break;
}
default:
log.warn({ eventType: event.type }, "Unhandled Stripe event type");
}
log.info(
{
requestId,
procedure,
transport: "rest",
duration: Date.now() - start,
status: 200,
input: { eventType: event.type, eventId: event.id },
},
"API OK",
);
return res.status(200).json({ received: true });
} catch (err) {
log.error({ err }, "Stripe webhook handler failed");
log.error(
{
requestId,
procedure,
transport: "rest",
duration: Date.now() - start,
status: 400,
err,
},
"API error",
);
return res.status(400).json({ message: "Webhook handler failed" });
}
}

View File

@@ -1,52 +1,58 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { addYears } from "date-fns";
import { createNextApiContext } from "@kan/api/trpc";
import { integrations } from "@kan/db/schema";
import { addYears } from "date-fns";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { integrations } from "@kan/db/schema";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const { user } = await createNextApiContext(req);
const { user } = await createNextApiContext(req);
if (!user)
return res.status(401).json({ message: "User not authenticated" });
if (!user)
return res.status(401).json({ message: "User not authenticated" });
const apiKey = process.env.TRELLO_APP_API_KEY;
const apiKey = process.env.TRELLO_APP_API_KEY;
if (!apiKey)
return res.status(500).json({ message: "Trello API key not set in Environment Variables" });
if (!apiKey)
return res
.status(500)
.json({ message: "Trello API key not set in Environment Variables" });
const token = req.body.token;
const token = req.body.token;
if (!token)
return res.status(400).json({ message: "No token found" });
if (!token) return res.status(400).json({ message: "No token found" });
try {
const { db } = await createNextApiContext(req);
try {
const { db } = await createNextApiContext(req);
await db.insert(integrations).values({
provider: "trello",
userId: user.id,
accessToken: token,
expiresAt: addYears(new Date(), 1),
}).onConflictDoUpdate({
set: {
accessToken: token,
expiresAt: addYears(new Date(), 1),
},
target: [integrations.userId, integrations.provider],
});
await db
.insert(integrations)
.values({
provider: "trello",
userId: user.id,
accessToken: token,
expiresAt: addYears(new Date(), 1),
})
.onConflictDoUpdate({
set: {
accessToken: token,
expiresAt: addYears(new Date(), 1),
},
target: [integrations.userId, integrations.provider],
});
return res.status(200).json({ message: "Trello authentication successful" });
} catch (err) {
console.error("Trello authentication error:", err);
return res.status(400).json({ message: "Trello authentication failed" });
}
},
);
return res
.status(200)
.json({ message: "Trello authentication successful" });
} catch (err) {
return res.status(400).json({ message: "Trello authentication failed" });
}
}),
);

View File

@@ -3,9 +3,11 @@ import { Novu } from "@novu/api";
import { jwtVerify } from "jose";
import { z } from "zod";
import { env } from "~/env";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { env } from "~/env";
const requestSchema = z.object({
token: z.string().min(1),
});
@@ -22,84 +24,85 @@ const textEncoder = new TextEncoder();
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
return res.status(404).json({
success: false,
error: "Unsubscribe endpoint is not available.",
code: "UNAVAILABLE",
});
}
withApiLogging(
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
return res.status(404).json({
success: false,
error: "Unsubscribe endpoint is not available.",
code: "UNAVAILABLE",
});
}
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).json({
success: false,
error: "Method not allowed.",
code: "METHOD_NOT_ALLOWED",
});
}
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).json({
success: false,
error: "Method not allowed.",
code: "METHOD_NOT_ALLOWED",
});
}
const parsedBody = requestSchema.safeParse(req.body);
const parsedBody = requestSchema.safeParse(req.body);
if (!parsedBody.success) {
return res.status(400).json({
success: false,
error: "Invalid request payload.",
code: "BAD_REQUEST",
});
}
if (!parsedBody.success) {
return res.status(400).json({
success: false,
error: "Invalid request payload.",
code: "BAD_REQUEST",
});
}
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
return res.status(500).json({
success: false,
error: "Unsubscribe service is not configured.",
code: "NOT_CONFIGURED",
});
}
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
return res.status(500).json({
success: false,
error: "Unsubscribe service is not configured.",
code: "NOT_CONFIGURED",
});
}
let payload: z.infer<typeof tokenPayloadSchema>;
let payload: z.infer<typeof tokenPayloadSchema>;
try {
const verified = await jwtVerify(
parsedBody.data.token,
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
{
// We intentionally do not use exp/iat claims
// tokens are long-lived and validated only by signature + payload.
clockTolerance: "0s",
},
);
payload = tokenPayloadSchema.parse(verified.payload);
} catch {
return res.status(401).json({
success: false,
error: "Your unsubscribe link is invalid or has expired.",
code: "INVALID_TOKEN",
});
}
try {
const verified = await jwtVerify(
parsedBody.data.token,
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
{
// We intentionally do not use exp/iat claims
// tokens are long-lived and validated only by signature + payload.
clockTolerance: "0s",
},
);
payload = tokenPayloadSchema.parse(verified.payload);
} catch {
return res.status(401).json({
success: false,
error: "Your unsubscribe link is invalid or has expired.",
code: "INVALID_TOKEN",
});
}
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
try {
await novu.subscribers.preferences.update(
{
channels: {
email: false,
},
},
payload.subscriberId,
);
} catch (error) {
console.error("Failed to update Novu preferences", error);
return res.status(502).json({
success: false,
error:
"We could not update your email preferences right now. Please try again later.",
code: "NOVU_ERROR",
});
}
try {
await novu.subscribers.preferences.update(
{
channels: {
email: false,
},
},
payload.subscriberId,
);
} catch (error) {
return res.status(502).json({
success: false,
error:
"We could not update your email preferences right now. Please try again later.",
code: "NOVU_ERROR",
});
}
return res.status(200).json({ success: true });
},
return res.status(200).json({ success: true });
},
),
);

View File

@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { Upload } from "@aws-sdk/lib-storage";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { assertPermission } from "@kan/api/utils/permissions";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as cardRepo from "@kan/db/repository/card.repo";
@@ -22,7 +23,7 @@ export const config = {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
@@ -36,7 +37,9 @@ export default withRateLimit(
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
if (!bucket) {
return res.status(500).json({ error: "Attachments bucket not configured" });
return res
.status(500)
.json({ error: "Attachments bucket not configured" });
}
const cardPublicId = req.query.cardPublicId;
@@ -55,7 +58,9 @@ export default withRateLimit(
}
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" });
return res
.status(400)
.json({ error: "Missing or invalid content length" });
}
if (contentLength > MAX_SIZE_BYTES) {
@@ -129,8 +134,7 @@ export default withRateLimit(
return res.status(200).json({ attachment });
} catch (error) {
console.error("Attachment upload failed", error);
return res.status(500).json({ error: "Internal server error" });
}
},
}),
);

View File

@@ -2,13 +2,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { createNextApiContext } from "@kan/api/trpc";
import * as userRepo from "@kan/db/repository/user.repo";
import { env } from "~/env";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as userRepo from "@kan/db/repository/user.repo";
import { createS3Client } from "@kan/shared/utils";
const MAX_SIZE_BYTES = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB
import { env } from "~/env";
const MAX_SIZE_BYTES = parseInt(
process.env.S3_AVATAR_UPLOAD_LIMIT || "2097152",
10,
); // Default 2MB
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
export const config = {
@@ -19,7 +23,7 @@ export const config = {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
@@ -51,7 +55,9 @@ export default withRateLimit(
}
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" });
return res
.status(400)
.json({ error: "Missing or invalid content length" });
}
if (contentLength > MAX_SIZE_BYTES) {
@@ -93,9 +99,7 @@ export default withRateLimit(
user: updatedUser,
});
} catch (error) {
console.error("Avatar upload failed", error);
return res.status(500).json({ error: "Internal server error" });
}
},
}),
);

View File

@@ -0,0 +1,31 @@
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { env } from "next-runtime-env";
import { authClient } from "@kan/auth/client";
import { PageHead } from "~/components/PageHead";
import SelectPlanView from "~/views/onboarding/select-plan";
export default function SelectPlanPage() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
useEffect(() => {
if (!isPending && !session?.user) {
router.push("/login");
}
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
router.push("/boards");
}
}, [session, isPending, router]);
if (isPending || !session?.user) return null;
return (
<>
<PageHead title="Select plan | kan.bn" />
<SelectPlanView />
</>
);
}

View File

@@ -0,0 +1,28 @@
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { authClient } from "@kan/auth/client";
import { PageHead } from "~/components/PageHead";
import WorkspaceDetailsView from "~/views/onboarding/workspace-details";
export default function WorkspaceDetailsPage() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
useEffect(() => {
if (!isPending && !session?.user) router.push("/login");
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud")
router.push("/boards");
}, [session, isPending, router]);
if (isPending || !session?.user) return null;
return (
<>
<PageHead title="Create workspace | kan.bn" />
<WorkspaceDetailsView />
</>
);
}

View File

@@ -0,0 +1,117 @@
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { authClient } from "@kan/auth/client";
import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
export default function PartnerActivatePage() {
const router = useRouter();
const searchParams = useSearchParams();
const licenseKey = searchParams.get("license_key");
const error = searchParams.get("error");
const partnerName = env("NEXT_PUBLIC_PARTNER_NAME");
const { data: session, isPending } = authClient.useSession();
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
useEffect(() => {
if (licenseKey) {
localStorage.setItem("partnerLicenseKey", licenseKey);
}
}, [licenseKey]);
useEffect(() => {
if (!isPending && session?.user && licenseKey) {
router.push(
`/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`,
);
} else if (!isPending && session?.user && !licenseKey) {
router.push("/boards");
}
}, [session, isPending, licenseKey, router]);
if (isPending || (session?.user && licenseKey)) return null;
return (
<>
<PageHead title={t`Activate | kan.bn`} />
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
<div className="z-10 flex w-full flex-col items-center">
<Link href="/">
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
kan.bn
</h1>
</Link>
<p className="mb-2 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
{isMagicLinkSent ? t`Check your inbox` : t`Activate your account`}
</p>
<p className="mb-10 text-sm text-light-800 dark:text-dark-800">
{isMagicLinkSent ? (
<Trans>We sent a link to {magicLinkRecipient}</Trans>
) : partnerName ? (
<Trans>
Sign in or create an account to activate your {partnerName}{" "}
license
</Trans>
) : (
t`Sign in or create an account to activate your license`
)}
</p>
{error && (
<div className="mb-4 w-full rounded-md bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400 sm:max-w-md">
{t`Something went wrong during activation. Please try again.`}
</div>
)}
{!isMagicLinkSent && (
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<Auth
setIsMagicLinkSent={(val, recipient) => {
setIsMagicLinkSent(val);
setMagicLinkRecipient(recipient);
}}
callbackURL={
licenseKey
? `/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`
: "/boards"
}
/>
</div>
</div>
)}
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
<Trans>
Already have an account?{" "}
<span className="underline">
<Link
href={
licenseKey
? `/login?next=${encodeURIComponent(`/api/partner/link?license_key=${licenseKey}`)}`
: "/login"
}
>
Sign in
</Link>
</span>
</Trans>
</p>
</div>
<PatternedBackground />
</div>
</main>
</>
);
}

View File

@@ -0,0 +1,31 @@
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { authClient } from "@kan/auth/client";
import { PageHead } from "~/components/PageHead";
import SelectPlanView from "~/views/onboarding/select-plan";
export default function UpgradeSelectPlanPage() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
useEffect(() => {
if (!isPending && !session?.user) {
router.push("/login");
}
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
router.push("/boards");
}
}, [session, isPending, router]);
if (isPending || !session?.user) return null;
return (
<>
<PageHead title="Upgrade | kan.bn" />
<SelectPlanView />
</>
);
}

View File

@@ -17,9 +17,10 @@ interface Workspace {
description: string | null | undefined;
publicId: string;
slug: string | undefined;
plan: "free" | "pro" | "enterprise" | undefined;
plan: "free" | "team" | "pro" | "enterprise" | undefined;
role: "admin" | "member" | "guest";
weekStartDay: 0 | 1 | 6;
cardPrefix: string;
}
const initialWorkspace: Workspace = {
@@ -27,9 +28,10 @@ const initialWorkspace: Workspace = {
description: null,
publicId: "",
slug: "",
plan: "free",
plan: "free" as const,
role: "member",
weekStartDay: 1,
cardPrefix: "",
};
const initialAvailableWorkspaces: Workspace[] = [];
@@ -50,7 +52,18 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
const workspacePublicId = useSearchParams().get("workspacePublicId");
const { data, isLoading } = api.workspace.all.useQuery();
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<string | null>(
workspacePublicId,
);
const pollAttemptsRef = React.useRef(0);
const MAX_POLL_ATTEMPTS = 5;
const { data, isLoading, isFetching } = api.workspace.all.useQuery(
undefined,
{
refetchInterval: pendingWorkspaceId ? 2000 : false,
},
);
const utils = api.useUtils();
const switchWorkspace = (_workspace: Workspace) => {
@@ -66,7 +79,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
if (!data?.length) {
if (!isLoading) setHasLoaded(true);
if (!isLoading && !isFetching) setHasLoaded(true);
return;
}
@@ -82,6 +95,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
description: workspace.description,
plan: workspace.plan,
weekStartDay: workspace.weekStartDay,
cardPrefix: workspace.cardPrefix,
hasLoaded: true,
})) as Workspace[];
@@ -89,44 +103,72 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
}
if (storedWorkspaceId !== null) {
const newData = data;
const selectedWorkspace = newData.find(
const selectedWorkspace = data.find(
({ workspace }) => workspace.publicId === storedWorkspaceId,
);
if (!selectedWorkspace?.workspace) return;
if (!selectedWorkspace?.workspace) {
if (pendingWorkspaceId) {
pollAttemptsRef.current += 1;
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
setPendingWorkspaceId(null);
localStorage.removeItem("workspacePublicId");
} else {
return;
}
} else {
// Clear stale workspacePublicId from localStorage
localStorage.removeItem("workspacePublicId");
}
} else {
pollAttemptsRef.current = 0;
setPendingWorkspaceId(null);
setWorkspace({
publicId: selectedWorkspace.workspace.publicId,
name: selectedWorkspace.workspace.name,
slug: selectedWorkspace.workspace.slug,
plan: selectedWorkspace.workspace.plan,
description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role,
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
});
setWorkspace({
publicId: selectedWorkspace.workspace.publicId,
name: selectedWorkspace.workspace.name,
slug: selectedWorkspace.workspace.slug,
plan: selectedWorkspace.workspace.plan,
description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role as "admin" | "member" | "guest",
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
cardPrefix: selectedWorkspace.workspace.cardPrefix,
});
if (workspacePublicId) {
router.push(`/boards`);
localStorage.setItem("workspacePublicId", workspacePublicId);
if (workspacePublicId) {
router.push(`/boards`);
localStorage.setItem("workspacePublicId", workspacePublicId);
}
setHasLoaded(true);
return;
}
} else {
const primaryWorkspace = data[0]?.workspace;
const primaryWorkspaceRole = data[0]?.role;
if (!primaryWorkspace || !primaryWorkspaceRole) return;
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
setWorkspace({
publicId: primaryWorkspace.publicId,
name: primaryWorkspace.name,
slug: primaryWorkspace.slug,
plan: primaryWorkspace.plan,
description: primaryWorkspace.description,
role: primaryWorkspaceRole,
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
});
}
}, [data, isLoading, workspacePublicId, router]);
const primaryWorkspace = data[0]?.workspace;
const primaryWorkspaceRole = data[0]?.role;
if (!primaryWorkspace || !primaryWorkspaceRole) return;
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
setWorkspace({
publicId: primaryWorkspace.publicId,
name: primaryWorkspace.name,
slug: primaryWorkspace.slug,
plan: primaryWorkspace.plan,
description: primaryWorkspace.description,
role: primaryWorkspaceRole as "admin" | "member" | "guest",
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
cardPrefix: primaryWorkspace.cardPrefix,
});
setHasLoaded(true);
}, [
data,
isLoading,
isFetching,
workspacePublicId,
pendingWorkspaceId,
router,
]);
return (
<WorkspaceContext.Provider

View File

@@ -1,3 +1,5 @@
import { env } from "next-runtime-env";
export const formatToArray = (
value: string | string[] | undefined,
): string[] => {
@@ -50,5 +52,21 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
return imageOrKey;
}
// Construct URL from S3 key
const useVirtualHosted = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS") === "true";
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
if (useVirtualHosted && storageDomain && bucket) {
// Virtual-hosted style: https://{bucket}.{domain}/{key}
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
}
if (storageUrl && bucket) {
// Path-style: {storageUrl}/{bucket}/{key}
return `${storageUrl}/${bucket}/${imageOrKey}`;
}
return "";
};

View File

@@ -22,8 +22,8 @@ const loadMessages = async (locale: Locale) => {
return (await import("~/locales/ru/messages")).messages;
case "pl":
return (await import("~/locales/pl/messages")).messages;
case "pt-BR":
return (await import("~/locales/pt-BR/messages")).messages;
case "ptbr":
return (await import("~/locales/ptbr/messages")).messages;
default:
return enMessages;
}

View File

@@ -1,3 +1,4 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
@@ -29,6 +30,7 @@ export default function BoardDropdown({
isFavorite?: boolean;
boardName?: string;
}) {
const router = useRouter();
const { openModal } = useModal();
const { showPopup } = usePopup();
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
@@ -47,6 +49,7 @@ export default function BoardDropdown({
: t`The board has been unarchived.`,
icon: "success",
});
void router.push(`/boards`);
} else if (variables.favorite !== undefined) {
showPopup({
header: variables.favorite

View File

@@ -16,6 +16,7 @@ import { getAvatarUrl } from "~/utils/helpers";
const Card = ({
title,
ticketNumber,
labels,
members,
checklists,
@@ -25,6 +26,7 @@ const Card = ({
dueDate,
}: {
title: string;
ticketNumber?: string | null;
labels: { name: string; colourCode: string | null }[];
members: {
publicId: string;
@@ -67,6 +69,11 @@ const Card = ({
return (
<div className="flex flex-col overflow-hidden 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">
{ticketNumber && (
<span className="mb-1 text-xs text-light-700 dark:text-dark-800">
{ticketNumber}
</span>
)}
<span className="break-words">{title}</span>
{labels.length ||
members.length ||

View File

@@ -0,0 +1,41 @@
import { t } from "@lingui/core/macro";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
import { DueDateSelector } from "~/views/card/components/DueDateSelector";
export function CardContextDueDateModal() {
const { entityId: cardPublicId, closeModal } = useModal();
const { data: card, isLoading } = api.card.byId.useQuery(
{ cardPublicId: cardPublicId ?? "" },
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
);
if (!cardPublicId) return null;
return (
<div className="p-4">
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
{t`Set due date`}
</h2>
{isLoading ? (
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
) : (
<DueDateSelector
cardPublicId={cardPublicId}
dueDate={card?.dueDate ?? null}
/>
)}
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={closeModal}
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
>
{t`Done`}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,284 @@
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
Transition,
} from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { useState } from "react";
import { HiChevronDown } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
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";
interface CardContextDuplicateModalProps {
boardPublicId?: string;
isTemplate?: boolean;
}
export function CardContextDuplicateModal({
boardPublicId: boardPublicIdProp,
isTemplate: isTemplateProp,
}: CardContextDuplicateModalProps = {}) {
const { entityId: cardPublicId, closeModal, getModalState } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const modalState = getModalState("CARD_CONTEXT_DUPLICATE") as
| { boardPublicId: string; isTemplate?: boolean }
| undefined;
const boardPublicId =
boardPublicIdProp ?? modalState?.boardPublicId ?? "";
const isTemplate = isTemplateProp ?? modalState?.isTemplate ?? false;
const [listPublicId, setListPublicId] = useState("");
const [copyLabels, setCopyLabels] = useState(true);
const [copyMembers, setCopyMembers] = useState(true);
const [copyChecklists, setCopyChecklists] = useState(true);
const [position, setPosition] = useState<string>("");
const [title, setTitle] = useState("");
const { data: card, isLoading: isCardLoading } = api.card.byId.useQuery(
{ cardPublicId: cardPublicId ?? "" },
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
);
const boardType = isTemplate ? "template" : "regular";
const { data: board } = api.board.byId.useQuery(
{ boardPublicId, type: boardType },
{ enabled: !!boardPublicId },
);
const lists = board?.lists ?? [];
const listOptions = lists.map((l) => ({ publicId: l.publicId, name: l.name }));
const currentListPublicId = card?.list?.publicId;
const hasLabels = (card?.labels?.length ?? 0) > 0;
const hasMembers = (card?.members?.length ?? 0) > 0;
const hasChecklists = (card?.checklists?.length ?? 0) > 0;
const hasAnyCopyOption = hasLabels || hasMembers || hasChecklists;
const duplicateCard = api.card.duplicate.useMutation({
onSuccess: () => {
showPopup({
header: t`Card duplicated`,
icon: "success",
message: t`The card has been duplicated.`,
});
closeModal();
},
onError: () => {
showPopup({
header: t`Unable to duplicate card`,
message: t`Please try again.`,
icon: "error",
});
},
onSettled: async () => {
await utils.board.byId.invalidate();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!cardPublicId || !listPublicId) return;
const indexNum =
position === "" ? undefined : parseInt(position, 10);
if (
position !== "" &&
(indexNum === undefined || isNaN(indexNum) || indexNum < 0)
)
return;
duplicateCard.mutate({
cardPublicId,
listPublicId,
copyLabels,
copyMembers,
copyChecklists,
...(typeof indexNum === "number" && { index: indexNum }),
title: title.trim() || undefined,
});
};
if (!cardPublicId) return null;
return (
<form onSubmit={handleSubmit} className="p-4">
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
{t`Duplicate card`}
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`List`}
</label>
<Listbox value={listPublicId} onChange={setListPublicId}>
<div className="relative">
<ListboxButton
className={twMerge(
"relative w-full cursor-pointer rounded-md border border-light-300 bg-white py-2 pl-3 pr-10 text-left text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-light-400 focus:ring-offset-0 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:focus:ring-dark-500",
!listPublicId && "text-light-600 dark:text-dark-600",
)}
>
<span className="block truncate">
{listPublicId
? listOptions.find((o) => o.publicId === listPublicId)
?.name
: t`Select a list`}
</span>
<span className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
<HiChevronDown
className="h-4 w-4 text-light-600 dark:text-dark-600"
aria-hidden
/>
</span>
</ListboxButton>
<Transition
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute z-50 mt-1 w-full overflow-hidden rounded-md border border-light-200 bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-200">
<div className="max-h-60 overflow-y-auto py-1 pr-1 scrollbar scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
{listOptions.map((option) => {
const isCurrentList =
option.publicId === currentListPublicId;
return (
<ListboxOption
key={option.publicId}
value={option.publicId}
disabled={isCurrentList}
className={({ focus }) =>
twMerge(
"relative select-none py-2 pl-3 pr-9 text-sm",
isCurrentList
? "cursor-default opacity-50"
: "cursor-pointer",
!isCurrentList && focus
? "bg-light-200 text-light-1000 dark:bg-dark-400 dark:text-dark-1000"
: "text-light-900 dark:text-dark-900",
)
}
>
{option.name}
</ListboxOption>
);
})}
</div>
</ListboxOptions>
</Transition>
</div>
</Listbox>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`Title (optional)`}
</label>
<Input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={card?.title ?? ""}
className="w-full"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`Position in list (0 = first, leave empty for end)`}
</label>
<Input
type="number"
min={0}
value={position}
onChange={(e) => setPosition(e.target.value)}
placeholder={t`End of list`}
className="w-full"
/>
</div>
{hasAnyCopyOption && (
<div className="flex flex-col gap-2">
{hasLabels && (
<div className="flex items-center gap-3">
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
<input
type="checkbox"
checked={copyLabels}
onChange={(e) => setCopyLabels(e.target.checked)}
className={twMerge(
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
"cursor-pointer",
)}
/>
</label>
<span className="text-sm text-light-900 dark:text-dark-900">
{t`Copy labels`}
</span>
</div>
)}
{hasMembers && (
<div className="flex items-center gap-3">
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
<input
type="checkbox"
checked={copyMembers}
onChange={(e) => setCopyMembers(e.target.checked)}
className={twMerge(
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
"cursor-pointer",
)}
/>
</label>
<span className="text-sm text-light-900 dark:text-dark-900">
{t`Copy members`}
</span>
</div>
)}
{hasChecklists && (
<div className="flex items-center gap-3">
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
<input
type="checkbox"
checked={copyChecklists}
onChange={(e) => setCopyChecklists(e.target.checked)}
className={twMerge(
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
"cursor-pointer",
)}
/>
</label>
<span className="text-sm text-light-900 dark:text-dark-900">
{t`Copy checklists`}
</span>
</div>
)}
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={closeModal}>
{t`Cancel`}
</Button>
<Button
type="submit"
variant="primary"
disabled={
!listPublicId ||
duplicateCard.isPending ||
isCardLoading ||
lists.length === 0
}
>
{duplicateCard.isPending ? t`Duplicating…` : t`Duplicate`}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,53 @@
import { t } from "@lingui/core/macro";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
import LabelIcon from "~/components/LabelIcon";
import LabelSelector from "~/views/card/components/LabelSelector";
export function CardContextLabelsModal() {
const { entityId: cardPublicId, closeModal } = useModal();
const { data: card, isLoading } = api.card.byId.useQuery(
{ cardPublicId: cardPublicId ?? "" },
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
);
const boardLabels = card?.list?.board?.labels ?? [];
const selectedLabels = card?.labels ?? [];
const formattedLabels = boardLabels.map((label) => ({
key: label.publicId,
value: label.name,
selected: selectedLabels.some((l) => l.publicId === label.publicId),
leftIcon: <LabelIcon colourCode={label.colourCode} />,
}));
if (!cardPublicId) return null;
return (
<div className="p-4">
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
{t`Labels`}
</h2>
{isLoading ? (
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
) : (
<LabelSelector
cardPublicId={cardPublicId}
labels={formattedLabels}
isLoading={isLoading}
/>
)}
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={closeModal}
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
>
{t`Done`}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { t } from "@lingui/core/macro";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
import Avatar from "~/components/Avatar";
import MemberSelector from "~/views/card/components/MemberSelector";
export function CardContextMembersModal() {
const { entityId: cardPublicId, closeModal } = useModal();
const { data: card, isLoading } = api.card.byId.useQuery(
{ cardPublicId: cardPublicId ?? "" },
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
);
const board = card?.list?.board;
const workspaceMembers = board?.workspace?.members ?? [];
const selectedMembers = card?.members ?? [];
const formattedMembers = workspaceMembers.map((member) => {
const isSelected = selectedMembers.some(
(m) => m.publicId === member.publicId,
);
return {
key: member.publicId,
value: formatMemberDisplayName(
member.user?.name ?? null,
member.user?.email ?? member.email,
),
imageUrl: member.user?.image ? getAvatarUrl(member.user.image) : undefined,
selected: isSelected,
leftIcon: (
<Avatar
size="xs"
name={member.user?.name ?? ""}
imageUrl={
member.user?.image ? getAvatarUrl(member.user.image) : undefined
}
email={member.user?.email ?? member.email}
/>
),
};
});
if (!cardPublicId) return null;
return (
<div className="p-4">
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
{t`Manage members`}
</h2>
{isLoading ? (
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
) : (
<MemberSelector
cardPublicId={cardPublicId}
members={formattedMembers}
isLoading={isLoading}
/>
)}
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={closeModal}
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
>
{t`Done`}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { t } from "@lingui/core/macro";
import { useEffect, useRef } from "react";
import {
HiLink,
HiOutlineCalendar,
HiOutlineDocumentDuplicate,
HiOutlineTag,
HiOutlineTrash,
HiOutlineUserGroup,
HiOutlineArrowRightCircle,
} from "react-icons/hi2";
export type CardContextMenuAction =
| "members"
| "move"
| "labels"
| "dueDate"
| "copyLink"
| "duplicate"
| "delete";
interface CardContextMenuProps {
x: number;
y: number;
onClose: () => void;
onAction: (action: CardContextMenuAction) => void;
canEdit: boolean;
}
const MENU_ITEMS: {
action: CardContextMenuAction;
label: string;
icon: React.ReactNode;
requiresEdit: boolean;
}[] = [
{
action: "members",
label: t`Manage members`,
icon: <HiOutlineUserGroup className="h-4 w-4 shrink-0" />,
requiresEdit: true,
},
{
action: "move",
label: t`Move to another list`,
icon: <HiOutlineArrowRightCircle className="h-4 w-4 shrink-0" />,
requiresEdit: true,
},
{
action: "labels",
label: t`Add / edit label`,
icon: <HiOutlineTag className="h-4 w-4 shrink-0" />,
requiresEdit: true,
},
{
action: "dueDate",
label: t`Set due date`,
icon: <HiOutlineCalendar className="h-4 w-4 shrink-0" />,
requiresEdit: true,
},
{
action: "copyLink",
label: t`Copy link to card`,
icon: <HiLink className="h-4 w-4 shrink-0" />,
requiresEdit: false,
},
{
action: "duplicate",
label: t`Duplicate card`,
icon: <HiOutlineDocumentDuplicate className="h-4 w-4 shrink-0" />,
requiresEdit: true,
},
{
action: "delete",
label: t`Delete card`,
icon: <HiOutlineTrash className="h-4 w-4 shrink-0" />,
requiresEdit: true,
},
];
export function CardContextMenu({
x,
y,
onClose,
onAction,
canEdit,
}: CardContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
const items = MENU_ITEMS.filter((item) => !item.requiresEdit || canEdit);
return (
<div
ref={menuRef}
className="fixed z-[200] min-w-[200px] rounded-md border border-light-200 bg-white py-1 shadow-lg dark:border-dark-400 dark:bg-dark-200"
style={{ left: x, top: y }}
>
{items.map(({ action, label, icon }) => (
<button
key={action}
type="button"
onClick={() => {
onAction(action);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
{icon}
{label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { t } from "@lingui/core/macro";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
export function CardContextMoveListModal() {
const { entityId: cardPublicId, closeModal } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const { data: card, isLoading } = api.card.byId.useQuery(
{ cardPublicId: cardPublicId ?? "" },
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
);
const updateCardList = api.card.update.useMutation({
onMutate: async (vars) => {
if (!cardPublicId) return undefined;
await utils.card.byId.cancel();
const previous = utils.card.byId.getData({ cardPublicId });
utils.card.byId.setData({ cardPublicId }, (old) => {
if (!old) return old;
const list = old.list.board?.lists?.find(
(l) => l.publicId === vars.listPublicId,
);
if (!list) return old;
return {
...old,
list: { ...old.list, publicId: list.publicId, name: list.name },
};
});
return { previous };
},
onError: (_err, _vars, ctx) => {
if (cardPublicId && ctx?.previous) {
utils.card.byId.setData({ cardPublicId }, ctx.previous);
}
showPopup({
header: t`Unable to move card`,
message: t`Please try again.`,
icon: "error",
});
},
onSettled: async () => {
if (cardPublicId) {
await invalidateCard(utils, cardPublicId);
await utils.board.byId.invalidate();
}
},
});
const lists = card?.list?.board?.lists ?? [];
const currentListPublicId = card?.list?.publicId;
const handleSelectList = (listPublicId: string) => {
if (listPublicId === currentListPublicId || !cardPublicId) return;
updateCardList.mutate(
{ cardPublicId, listPublicId, index: 0 },
{ onSuccess: closeModal },
);
};
if (!cardPublicId) return null;
return (
<div className="p-4">
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
{t`Move to list`}
</h2>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300"
/>
))}
</div>
) : (
<div className="max-h-[60vh] overflow-y-auto pr-1 scrollbar scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
<ul className="space-y-1">
{lists.map((list) => (
<li key={list.publicId}>
<button
type="button"
onClick={() => handleSelectList(list.publicId)}
disabled={list.publicId === currentListPublicId}
className="w-full rounded-md px-3 py-2 text-left text-sm hover:bg-light-200 disabled:opacity-50 dark:hover:bg-dark-400"
>
{list.name}
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@@ -146,6 +146,10 @@ export function NewCardForm({
listId: 2,
description: "",
dueDate: args.dueDate ?? null,
cardNumber: null,
comments: [],
checklists: [],
attachments: [],
labels: oldBoard.labels.filter((label) =>
args.labelPublicIds.includes(label.publicId),
),
@@ -158,6 +162,9 @@ export function NewCardForm({
...member,
deletedAt: null,
})) ?? [],
comments: [],
checklists: [],
attachments: [],
_filteredLabels: labelPublicIds.map((id) => ({ publicId: id })),
_filteredMembers: memberPublicIds.map((id) => ({ publicId: id })),
index: position === "start" ? 0 : list.cards.length,
@@ -515,7 +522,7 @@ export function NewCardForm({
</div>
</div>
<div className="mt-5 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-5 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Create another`}
isChecked={isCreateAnotherEnabled}

View File

@@ -136,7 +136,7 @@ export function NewListForm({
}}
/>
</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 space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Create another`}
isChecked={isCreateAnotherEnabled}

View File

@@ -4,6 +4,7 @@ import { useParams } from "next/navigation";
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { keepPreviousData } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { DragDropContext, Draggable } from "react-beautiful-dnd";
import { useForm } from "react-hook-form";
@@ -15,6 +16,7 @@ import {
import type { UpdateBoardInput } from "@kan/api/types";
import type { CardContextMenuAction } from "./components/CardContextMenu";
import Button from "~/components/Button";
import { DeleteLabelConfirmation } from "~/components/DeleteLabelConfirmation";
import { LabelForm } from "~/components/LabelForm";
@@ -26,16 +28,23 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
import { Tooltip } from "~/components/Tooltip";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { useDragToScroll } from "~/hooks/useDragToScroll";
import { useScrollRestore } from "~/hooks/useScrollRestore";
import { usePermissions } from "~/hooks/usePermissions";
import { useScrollRestore } from "~/hooks/useScrollRestore";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { formatToArray } from "~/utils/helpers";
import { DeleteCardConfirmation } from "~/views/card/components/DeleteCardConfirmation";
import BoardDropdown from "./components/BoardDropdown";
import Card from "./components/Card";
import { CardContextDueDateModal } from "./components/CardContextDueDateModal";
import { CardContextDuplicateModal } from "./components/CardContextDuplicateModal";
import { CardContextLabelsModal } from "./components/CardContextLabelsModal";
import { CardContextMembersModal } from "./components/CardContextMembersModal";
import { CardContextMenu } from "./components/CardContextMenu";
import { CardContextMoveListModal } from "./components/CardContextMoveListModal";
import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
import Filters from "./components/Filters";
@@ -55,11 +64,18 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const utils = api.useUtils();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const { openModal, modalContentType, entityId, isOpen } = useModal();
const { openModal, modalContentType, entityId, isOpen, setModalState } =
useModal();
const [selectedPublicListId, setSelectedPublicListId] =
useState<PublicListId>("");
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
cardPublicId: string;
} | null>(null);
const { ref: scrollRef, onMouseDown } = useDragToScroll({
enabled: true,
direction: "horizontal",
@@ -134,7 +150,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
// Redirect to 404 if board doesn't exist
useEffect(() => {
if (router.isReady && boardId && !isQueryLoading) {
if (error?.data?.code === "NOT_FOUND" || (!boardData && !isQueryLoading)) {
if (
error?.data?.code === "NOT_FOUND" ||
(!boardData && !isQueryLoading)
) {
router.replace("/404");
}
}
@@ -152,7 +171,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const isLoading = isInitialLoading || isQueryLoading;
useScrollRestore(boardId, scrollRef, router, !isLoading && (boardData?.lists.length ?? 0) > 0);
useScrollRestore(
boardId,
scrollRef,
router,
!isLoading && (boardData?.lists.length ?? 0) > 0,
);
const updateListMutation = api.list.update.useMutation({
onMutate: async (args) => {
@@ -267,6 +291,56 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
setSelectedPublicListId(publicBoardId);
};
const handleCardContextMenuAction = (action: CardContextMenuAction) => {
const cardPublicId = contextMenu?.cardPublicId;
if (!cardPublicId) return;
setContextMenu(null);
if (action === "copyLink") {
const path = isTemplate
? `/templates/${boardId}/cards/${cardPublicId}`
: `/cards/${cardPublicId}`;
const url = `${typeof window !== "undefined" ? window.location.origin : ""}${path}`;
void navigator.clipboard.writeText(url).then(
() => {
showPopup({
header: t`Link copied`,
icon: "success",
message: t`Card URL copied to clipboard`,
});
},
() => {
showPopup({
header: t`Unable to copy link`,
icon: "error",
message: t`Please try again.`,
});
},
);
return;
}
if (action === "duplicate") {
setModalState("CARD_CONTEXT_DUPLICATE", {
boardPublicId: boardId ?? "",
isTemplate: !!isTemplate,
});
openModal("CARD_CONTEXT_DUPLICATE", cardPublicId);
return;
}
if (action === "delete") {
openModal("DELETE_CARD", cardPublicId);
return;
}
const modalType =
action === "members"
? "CARD_CONTEXT_MEMBERS"
: action === "move"
? "CARD_CONTEXT_MOVE_LIST"
: action === "labels"
? "CARD_CONTEXT_LABELS"
: "CARD_CONTEXT_DUE_DATE";
openModal(modalType, cardPublicId);
};
const onDragEnd = ({
source: _source,
destination,
@@ -403,6 +477,49 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
>
<EditYouTubeModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CARD_CONTEXT_MEMBERS"}
>
<CardContextMembersModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CARD_CONTEXT_MOVE_LIST"}
>
<CardContextMoveListModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CARD_CONTEXT_LABELS"}
>
<CardContextLabelsModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CARD_CONTEXT_DUE_DATE"}
>
<CardContextDueDateModal />
</Modal>
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "CARD_CONTEXT_DUPLICATE"}
>
<CardContextDuplicateModal
boardPublicId={boardId ?? ""}
isTemplate={!!isTemplate}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_CARD"}
>
<DeleteCardConfirmation
cardPublicId={entityId}
boardPublicId={boardId ?? ""}
/>
</Modal>
</>
);
};
@@ -605,24 +722,46 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
)
e.preventDefault();
}}
onContextMenu={(e) => {
if (
card.publicId.startsWith(
"PLACEHOLDER",
) ||
env("NEXT_PUBLIC_KAN_ENV") ===
"cloud"
)
return;
e.preventDefault();
setContextMenu({
x: e.clientX,
y: e.clientY,
cardPublicId: card.publicId,
});
}}
key={card.publicId}
href={
isTemplate
? `/templates/${boardId}/cards/${card.publicId}`
: `/cards/${card.publicId}`
}
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
className={`mb-2 flex !cursor-pointer flex-col ${
card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<Card
title={card.title}
ticketNumber={
card.cardNumber != null
? `${boardData.workspace.cardPrefix}-${card.cardNumber}`
: null
}
labels={card.labels}
members={card.members}
checklists={card.checklists ?? []}
@@ -653,6 +792,15 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
</>
) : null}
</div>
{contextMenu && (
<CardContextMenu
x={contextMenu.x}
y={contextMenu.y}
onClose={() => setContextMenu(null)}
onAction={handleCardContextMenuAction}
canEdit={!!canEditCard}
/>
)}
{renderModalContent()}
</div>
</>

View File

@@ -184,7 +184,7 @@ const SelectSource = ({
/>
</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 space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button
type="submit"
@@ -310,7 +310,7 @@ const ImportGithub: React.FC = () => {
<form onSubmit={handleSubmitProjects(onSubmitProjects)}>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</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 space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Select all`}
isChecked={!!isSelectAllEnabled}
@@ -459,7 +459,7 @@ const ImportTrello: React.FC = () => {
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</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 space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Select all`}
isChecked={!!isSelectAllEnabled}

View File

@@ -148,7 +148,7 @@ export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
showTemplates={showTemplates}
customTemplates={formattedTemplates ?? []}
/>
<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 space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{!isTemplate && (
<Toggle
label={t`Use template`}

View File

@@ -525,7 +525,6 @@ const ActivityList = ({
comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt}
isAuthor={activity.comment?.createdBy === sessionData?.user.id}
isAdmin={isAdmin ?? false}
isViewOnly={!!isViewOnly}
/>
);

View File

@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
HiHashtag,
HiLink,
HiOutlineCheckCircle,
HiOutlineTrash,
@@ -18,11 +19,13 @@ export default function CardDropdown({
isTemplate,
boardPublicId,
cardCreatedBy,
ticketNumber,
}: {
cardPublicId: string;
isTemplate?: boolean;
boardPublicId?: string;
cardCreatedBy?: string | null;
ticketNumber?: string | null;
}) {
const { openModal } = useModal();
const { showPopup } = usePopup();
@@ -53,12 +56,40 @@ export default function CardDropdown({
}
};
const handleCopyTicketId = async () => {
if (!ticketNumber) return;
try {
await navigator.clipboard.writeText(ticketNumber);
showPopup({
header: t`ID copied`,
icon: "success",
message: t`Ticket ID copied to clipboard`,
});
} catch (error) {
console.error(error);
showPopup({
header: t`Unable to copy ID`,
icon: "error",
message: t`Please try again.`,
});
}
};
const items = [
{
label: t`Copy card link`,
action: handleCopyCardLink,
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
...(ticketNumber
? [
{
label: t`Copy ticket ID`,
action: handleCopyTicketId,
icon: <HiHashtag className="h-[16px] w-[16px] text-dark-900" />,
},
]
: []),
...(canEditCard
? [
{

View File

@@ -78,6 +78,7 @@ export default function LabelSelector({
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.board.byId.invalidate();
},
});

View File

@@ -58,6 +58,7 @@ export default function ListSelector({
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.board.byId.invalidate();
},
});

View File

@@ -85,6 +85,7 @@ export default function MemberSelector({
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.board.byId.invalidate();
},
});

View File

@@ -340,6 +340,14 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
>
{board?.name}
</Link>
{card.cardNumber != null && card.list.board.workspace.cardPrefix && (
<>
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<Dropdown
@@ -347,6 +355,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
isTemplate={isTemplate}
boardPublicId={boardId}
cardCreatedBy={card?.createdBy}
ticketNumber={
card.cardNumber != null && card.list.board.workspace.cardPrefix
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
: null
}
/>
<Link
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}

View File

@@ -13,7 +13,6 @@ import { z } from "zod";
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";
@@ -25,15 +24,11 @@ import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function InviteMemberForm({
numberOfMembers,
subscriptions,
unlimitedSeats,
userId,
}: {
numberOfMembers: number;
subscriptions: Subscription[] | undefined;
unlimitedSeats: boolean;
userId: string | undefined;
}) {
const utils = api.useUtils();
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
@@ -153,6 +148,9 @@ export function InviteMemberForm({
const hasTeamSubscription = !!teamSubscription;
const hasProSubscription = !!proSubscription;
const isPartnerTier = !!(
teamSubscription?.partnerTier ?? proSubscription?.partnerTier
);
let isYearly = false;
let price = t`$10/month`;
@@ -175,13 +173,13 @@ export function InviteMemberForm({
inviteMember.mutate(member);
};
const isFreePlan =
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription;
const handleInviteLinkToggle = async () => {
if (
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
)
return handleUpgrade();
if (isFreePlan) return;
setIsLoadingInviteLink(true);
@@ -219,31 +217,6 @@ export function InviteMemberForm({
}
};
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(() => {
const emailElement: HTMLElement | null =
document.querySelector<HTMLElement>("#email");
@@ -270,11 +243,7 @@ export function InviteMemberForm({
<Input
id="email"
placeholder={t`Email`}
disabled={
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
}
disabled={isFreePlan}
{...register("email", { required: true })}
onKeyDown={async (e) => {
if (e.key === "Enter") {
@@ -285,78 +254,86 @@ export function InviteMemberForm({
errorMessage={errors.email?.message}
/>
)}
{(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
<div className="my-4">
<div className="relative">
<Input
value={inviteLink}
className="pr-10 text-sm text-light-900 dark:text-dark-900"
readOnly
/>
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
onClick={copyToClipboard}
>
{copied ? (
<HiMiniCheck className="h-5 w-5 text-green-600" />
) : (
<HiOutlineDocumentDuplicate className="h-5 w-5" />
)}
</button>
{!isFreePlan &&
(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
<div className="my-4">
<div className="relative">
<Input
value={inviteLink}
className="pr-10 text-sm text-light-900 dark:text-dark-900"
readOnly
/>
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
onClick={copyToClipboard}
>
{copied ? (
<HiMiniCheck className="h-5 w-5 text-green-600" />
) : (
<HiOutlineDocumentDuplicate className="h-5 w-5" />
)}
</button>
</div>
<div className="mt-2 flex items-start gap-1">
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
<p className="text-xs text-gray-500 dark:text-dark-900">
{t`Anyone with this link can join your workspace`}
</p>
</div>
</div>
<div className="mt-2 flex items-start gap-1">
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
<p className="text-xs text-gray-500 dark:text-dark-900">
{t`Anyone with this link can join your workspace`}
</p>
</div>
</div>
)}
)}
{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>
)}
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!isPartnerTier &&
!unlimitedSeats && (
<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>
{!isPartnerTier && (
<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 or Pro plan. You'll be redirected to upgrade your workspace.`}
</p>
</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">
<Toggle
label={
isShareInviteLinkEnabled
? t`Deactivate invite link`
: t`Create invite link`
}
isChecked={isShareInviteLinkEnabled}
onChange={handleInviteLinkToggle}
/>
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{!isFreePlan && (
<Toggle
label={
isShareInviteLinkEnabled
? t`Deactivate invite link`
: t`Create invite link`
}
isChecked={isShareInviteLinkEnabled}
onChange={handleInviteLinkToggle}
/>
)}
<div>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription ? (
<Button type="button" onClick={handleUpgrade}>
{t`Start 14 day free trial`}
{isFreePlan ? (
<Button
type="button"
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
>
{t`Choose plan`}
</Button>
) : (
<Button

View File

@@ -27,8 +27,8 @@ import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { getAvatarUrl } from "~/utils/helpers";
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
import { InviteMemberForm } from "./components/InviteMemberForm";
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
import { InviteMemberForm } from "./components/InviteMemberForm";
export default function MembersPage() {
const { modalContentType, openModal, isOpen } = useModal();
@@ -76,6 +76,19 @@ export default function MembersPage() {
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
const isProPlan =
!!proSubscription ||
workspace.plan === "pro" ||
workspace.plan === "enterprise";
const isTeamPlan = !!teamSubscription || workspace.plan === "team";
const isPaidPlan = isProPlan || isTeamPlan;
const activeMembers = data?.members.length ?? 0;
const totalSeats =
teamSubscription?.seats ??
proSubscription?.seats ??
(isPaidPlan ? null : 1);
const TableRow = ({
memberPublicId,
memberId,
@@ -144,8 +157,8 @@ export default function MembersPage() {
{memberName}
</p>
</div>
{((workspace.role === "admin" ||
data?.showEmailsToMembers === true) ||
{(workspace.role === "admin" ||
data?.showEmailsToMembers === true ||
showSkeleton) && (
<p
className={twMerge(
@@ -180,8 +193,7 @@ export default function MembersPage() {
<div className="relative inline-flex items-center">
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]">
{memberRole &&
memberRole.charAt(0).toUpperCase() +
memberRole.slice(1)}
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
{canEditMember && session?.user.id !== memberId && (
<HiChevronDown className="h-3 w-3" />
)}
@@ -264,38 +276,42 @@ export default function MembersPage() {
</h1>
</div>
<div className="flex items-center gap-3">
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && !!data && (
<>
{!proSubscription && !teamSubscription && (
{!isPaidPlan && (
<Link
href="/settings/workspace?upgrade=pro"
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
className="hidden items-center rounded-full border border-emerald-300 bg-emerald-50 px-3 py-1 text-center text-xs text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400 lg:flex"
>
<HiBolt />
<span className="ml-1 font-medium">
{t`Launch offer: Get unlimited members with Pro`}
</span>
<span className="ml-1 font-medium">{t`Upgrade`}</span>
</Link>
)}
<div
className={twMerge(
"flex items-center rounded-full border px-3 py-1 text-center text-xs",
teamSubscription || proSubscription
isPaidPlan
? "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
{isProPlan
? t`Pro Plan`
: teamSubscription
: isTeamPlan
? t`Team Plan`
: t`Free Plan`}
{proSubscription && unlimitedSeats && (
<span className="ml-1 text-xs"></span>
)}
</span>
</div>
{isPaidPlan && (unlimitedSeats || totalSeats !== null) && (
<div className="flex items-center rounded-full border border-light-300 bg-light-50 px-3 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<span className="font-medium">
{unlimitedSeats
? t`Unlimited seats`
: `${activeMembers}/${totalSeats} ${t`seats`}`}
</span>
</div>
)}
</>
)}
<Button
@@ -384,8 +400,6 @@ export default function MembersPage() {
isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
>
<InviteMemberForm
userId={session?.user.id}
numberOfMembers={data?.members.length ?? 1}
subscriptions={subscriptions}
unlimitedSeats={unlimitedSeats}
/>

View File

@@ -0,0 +1,320 @@
import { useRouter, useSearchParams } from "next/navigation";
import { Radio, RadioGroup } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { HiUser } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import { api } from "~/utils/api";
type PlanId = "solo" | "team" | "pro";
type Billing = "monthly" | "annual";
// Each user orbits via a zero-size pivot div at center (144,144) that rotates.
// The icon is offset from the pivot by `radius` pixels along the x-axis.
// A counter-rotation on the icon keeps it upright.
const ORBIT_USERS: Record<
PlanId,
{ id: string; angle: number; radius: number; duration: number }[]
> = {
solo: [],
team: [
{ id: "t1", angle: 45, radius: 104, duration: 50 },
{ id: "t2", angle: 170, radius: 104, duration: 50 },
{ id: "t3", angle: 290, radius: 104, duration: 50 },
],
pro: [
{ id: "t1", angle: 45, radius: 104, duration: 50 },
{ id: "t2", angle: 170, radius: 104, duration: 50 },
{ id: "t3", angle: 290, radius: 104, duration: 50 },
{ id: "p1", angle: 10, radius: 144, duration: 70 },
{ id: "p2", angle: 120, radius: 144, duration: 70 },
{ id: "p4", angle: 240, radius: 144, duration: 70 },
{ id: "p3", angle: 230, radius: 64, duration: 30 },
],
};
export default function SelectPlanView() {
const router = useRouter();
const searchParams = useSearchParams();
const [selected, setSelected] = useState<PlanId>(
(searchParams.get("plan") as PlanId | null) ?? "solo",
);
const [billing, setBilling] = useState<Billing>(
(searchParams.get("billing") as Billing | null) ?? "annual",
);
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const workspacePublicId = searchParams.get("workspacePublicId");
const { data: workspaces } = api.workspace.all.useQuery();
const { data: session } = authClient.useSession();
const { data: user } = api.user.getUser.useQuery(undefined, {
enabled: !!session?.user,
});
const userImage = user?.image ?? session?.user.image ?? null;
const hasExistingWorkspace = !!workspaces?.length;
const FREQUENCIES: { value: Billing; label: string }[] = [
{ value: "monthly", label: t`Monthly` },
{ value: "annual", label: t`Annual` },
];
const PLANS: {
id: PlanId;
name: string;
monthly: string;
annual: string;
description: string;
trial?: boolean;
}[] = [
{
id: "solo",
name: t`Solo`,
monthly: t`Free`,
annual: t`Free`,
description: t`Good for individuals starting out who just need the essentials.`,
},
{
id: "team",
name: t`Team`,
monthly: "$10/user/mo",
annual: "$8/user/mo",
description: t`Best for small teams who want to collaborate and move faster together.`,
trial: true,
},
{
id: "pro",
name: t`Pro`,
monthly: "$29/mo",
annual: "$23/mo",
description: t`Unlimited members and a custom workspace username for teams ready to scale.`,
trial: true,
},
];
const buildSelectPlanUrl = (plan: PlanId, b: Billing) => {
const base = `/onboarding/select-plan?plan=${plan}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`;
return workspacePublicId
? `${base}&workspacePublicId=${workspacePublicId}`
: base;
};
const handleSelectPlan = (plan: PlanId) => {
setSelected(plan);
router.replace(buildSelectPlanUrl(plan, billing));
};
const handleSetBilling = (b: Billing) => {
setBilling(b);
router.replace(buildSelectPlanUrl(selected, b));
};
const handleContinue = async () => {
if (workspacePublicId && selected !== "solo") {
const response = await fetch("/api/stripe/create_checkout_session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
plan: selected,
billing,
workspacePublicId,
successUrl: returnUrl,
cancelUrl: returnUrl,
}),
});
const { url } = (await response.json()) as { url: string };
if (url) window.location.href = url;
return;
}
router.push(
`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
);
};
const handleCancel = () => router.push(returnUrl);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-light-100 px-4 py-8 dark:bg-dark-50 md:px-6">
<div className="w-full max-w-3xl overflow-hidden rounded-xl border border-light-400 bg-light-200 shadow-xl dark:border-dark-400 dark:bg-dark-100">
<div className="flex flex-col md:h-[520px] md:flex-row">
{/* Left panel */}
<div className="flex flex-col p-6 md:w-[55%] md:p-8">
<div className="flex-1">
<h2 className="text-xl font-bold text-light-1000 dark:text-dark-1000">
{t`Choose a plan`}
</h2>
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
{t`Pick a plan to get started. All paid plans include a 14-day free trial.`}
</p>
{/* Billing toggle */}
<div className="mt-4 flex justify-end">
<RadioGroup
value={billing}
onChange={handleSetBilling}
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600"
>
{FREQUENCIES.map((f) => (
<Radio
key={f.value}
value={f.value}
className={twMerge(
"cursor-pointer rounded-full px-2.5 py-0.5 text-xs transition-colors",
billing === f.value
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
: "text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200",
)}
>
{f.label}
</Radio>
))}
</RadioGroup>
</div>
<div className="mt-4 space-y-3">
{PLANS.map((plan) => {
const badge =
billing === "annual" ? plan.annual : plan.monthly;
return (
<button
key={plan.id}
type="button"
onClick={() => handleSelectPlan(plan.id)}
className={`relative w-full rounded-lg border px-4 py-3 text-left transition-colors ${
selected === plan.id
? "border-light-700 bg-light-300 dark:border-dark-600 dark:bg-dark-200"
: "border-light-500 bg-light-200 hover:border-light-600 dark:border-dark-500 dark:bg-dark-100 dark:hover:border-dark-600"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 pr-8">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-light-1000 dark:text-dark-1000">
{plan.name}
</span>
<span className="rounded-full bg-neutral-700 px-2 py-px text-[11px] font-medium text-neutral-200">
{badge}
</span>
{plan.trial && billing === "annual" && (
<span className="rounded-full bg-emerald-500/10 px-2 py-px text-[11px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400">
-20%
</span>
)}
</div>
<p className="mt-1 text-xs text-light-800 dark:text-dark-800">
{plan.description}
</p>
</div>
<div className="mt-0.5 flex-shrink-0">
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
selected === plan.id
? "border-light-900 bg-light-900 dark:border-dark-900 dark:bg-dark-900"
: "border-light-700 dark:border-dark-700"
}`}
>
{selected === plan.id && (
<div className="h-1.5 w-1.5 rounded-full bg-light-100 dark:bg-dark-100" />
)}
</div>
</div>
</div>
</button>
);
})}
</div>
</div>
<div className="mt-6 flex justify-end gap-2 md:mt-8">
{hasExistingWorkspace && (
<Button variant="ghost" onClick={handleCancel}>
{t`Cancel`}
</Button>
)}
<Button onClick={() => void handleContinue()}>
{workspacePublicId && selected !== "solo"
? t`Upgrade`
: t`Continue`}
</Button>
</div>
</div>
{/* Right panel */}
<div className="hidden items-center justify-center bg-light-200 dark:bg-dark-200 md:flex md:w-[45%]">
<div className="relative h-72 w-72">
{/* Orbit rings */}
<div className="absolute inset-0 m-auto h-72 w-72 rounded-full border border-light-400 dark:border-dark-400" />
<div className="absolute inset-0 m-auto h-52 w-52 rounded-full border border-light-400 dark:border-dark-400" />
<div className="absolute inset-0 m-auto h-32 w-32 rounded-full border border-light-400 dark:border-dark-400" />
{/* Centre person */}
<div className="absolute inset-0 m-auto flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-light-300 dark:bg-dark-300">
{userImage ? (
<img
src={userImage}
alt={t`Your avatar`}
className="h-full w-full object-cover"
/>
) : (
<HiUser className="h-7 w-7 text-light-700 dark:text-dark-700" />
)}
</div>
{/* Orbiting users — zero-size pivot at center rotates; icon offset by radius stays on the ring */}
<AnimatePresence>
{ORBIT_USERS[selected].map((u) => (
<motion.div
key={u.id}
style={{ position: "absolute", left: 144, top: 144 }}
initial={{ opacity: 0, rotate: u.angle }}
animate={{ opacity: 1, rotate: u.angle + 360 }}
exit={{ opacity: 0 }}
transition={{
opacity: { duration: 0.3 },
rotate: {
duration: u.duration,
repeat: Infinity,
ease: "linear",
repeatType: "loop",
},
}}
>
{/* Counter-rotate icon so it stays upright */}
<motion.div
style={{
position: "absolute",
left: u.radius - 12,
top: -12,
}}
initial={{ rotate: -u.angle }}
animate={{ rotate: -(u.angle + 360) }}
transition={{
duration: u.duration,
repeat: Infinity,
ease: "linear",
repeatType: "loop",
}}
className="flex h-6 w-6 items-center justify-center rounded-full bg-light-400 dark:bg-dark-400"
>
<HiUser className="h-3.5 w-3.5 text-light-800 dark:text-dark-800" />
</motion.div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</div>
</div>
{!hasExistingWorkspace && (
<Button variant="ghost" onClick={() => authClient.signOut()}>
{t`Sign out`}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,389 @@
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
import {
HiArrowLeft,
HiArrowPath,
HiArrowRight,
HiCheck,
HiEllipsisVertical,
HiInformationCircle,
HiLockClosed,
} from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import Input from "~/components/Input";
import LoadingSpinner from "~/components/LoadingSpinner";
import Toggle from "~/components/Toggle";
import { Tooltip } from "~/components/Tooltip";
import { useDebounce } from "~/hooks/useDebounce";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.slice(0, 60);
}
export default function WorkspaceNameView() {
const router = useRouter();
const searchParams = useSearchParams();
const plan = searchParams.get("plan") ?? "solo";
const billing = searchParams.get("billing") ?? "annual";
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const licenseKeyParam = searchParams.get("license_key");
const isLicenseFlow = !!licenseKeyParam;
const { showPopup } = usePopup();
useEffect(() => {
if (licenseKeyParam) {
localStorage.setItem("partnerLicenseKey", licenseKeyParam);
}
}, [licenseKeyParam]);
const [isProToggle, setIsProToggle] = useState(plan === "pro");
const effectivePlan = isProToggle ? "pro" : plan;
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
const [description, setDescription] = useState("");
const handleNameChange = (value: string) => {
setName(value);
if (isProToggle && !slugManuallyEdited) {
setSlug(slugify(value));
}
};
const [debouncedSlug] = useDebounce(slug, 500);
const isTyping = slug !== debouncedSlug;
const slugAvailability = api.workspace.checkSlugAvailability.useQuery(
{ workspaceSlug: debouncedSlug },
{ enabled: isProToggle && debouncedSlug.length >= 3 && !isTyping },
);
const isSlugAvailable =
slugAvailability.data?.isAvailable && !slugAvailability.data?.isReserved;
const isSlugTaken =
slugAvailability.data?.isAvailable === false &&
!slugAvailability.data?.isReserved;
const isSlugReserved = slugAvailability.data?.isReserved === true;
const slugError = isSlugTaken
? t`This URL has already been taken`
: isSlugReserved
? t`This URL is reserved`
: undefined;
const { data: session } = authClient.useSession();
const { data: user } = api.user.getUser.useQuery(undefined, {
enabled: !!session?.user,
});
const { data: workspaces } = api.workspace.all.useQuery();
const hasExistingWorkspace = !!workspaces?.length;
const utils = api.useUtils();
const previewSlug = isProToggle
? slugify(slug) || slugify(name) || "your-workspace"
: "your-workspace";
const createWorkspace = api.workspace.create.useMutation({
onSuccess: async (workspace) => {
if (!workspace.publicId) return;
localStorage.setItem("workspacePublicId", workspace.publicId);
void utils.workspace.all.invalidate();
const storedLicenseKey = localStorage.getItem("partnerLicenseKey");
if (storedLicenseKey) {
localStorage.removeItem("partnerLicenseKey");
router.push(
`/api/partner/link?license_key=${encodeURIComponent(storedLicenseKey)}`,
);
} else {
router.push("/boards");
}
},
onError: () => {
showPopup({
header: t`Unable to create workspace`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const [isRedirectingToCheckout, setIsRedirectingToCheckout] = useState(false);
const handleContinue = async () => {
if (!name.trim()) return;
if (effectivePlan === "solo" || isLicenseFlow) {
createWorkspace.mutate({
name: name.trim(),
...(description.trim() && { description: description.trim() }),
});
return;
}
// team/pro: redirect to Stripe — workspace created on checkout_success
setIsRedirectingToCheckout(true);
try {
const response = await fetch("/api/stripe/create_checkout_session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceName: name.trim(),
...(description.trim() && {
workspaceDescription: description.trim(),
}),
...(effectivePlan === "pro" && slug ? { workspaceSlug: slug } : {}),
cancelUrl: `${window.location.pathname}?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
successUrl: "/boards",
billing,
plan: effectivePlan,
}),
});
const data = await response.json();
const url = (data as { url: string }).url;
if (url) {
window.location.href = url;
return;
}
} catch {
// fall through
}
setIsRedirectingToCheckout(false);
showPopup({
header: t`Unable to start checkout`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
};
useEffect(() => {
document.getElementById("workspace-name-input")?.focus();
}, []);
const displayName = user?.name ?? session?.user.name ?? "";
const BOARDS = [t`Roadmap`, t`Engineering`, t`Marketing`];
const [visibleCount, setVisibleCount] = useState(1);
useEffect(() => {
const timers = BOARDS.slice(1).map((_, i) =>
setTimeout(() => setVisibleCount(i + 2), (i + 1) * 2000),
);
return () => timers.forEach(clearTimeout);
}, []);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-light-100 px-4 py-8 dark:bg-dark-50 md:px-6">
<div className="w-full max-w-3xl overflow-hidden rounded-xl border border-light-400 bg-light-200 shadow-xl dark:border-dark-400 dark:bg-dark-100">
<div className="flex flex-col md:h-[520px] md:flex-row">
{/* Left panel */}
<div className="flex flex-col p-6 md:w-[55%] md:p-8">
<div className="flex-1">
<h2 className="text-xl font-bold text-light-1000 dark:text-dark-1000">
{t`Set up your workspace`}
</h2>
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
{t`You can always change these later in settings.`}
</p>
<div className="mt-6 space-y-3">
<Input
id="workspace-name-input"
placeholder={t`Workspace name`}
value={name}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleContinue()}
maxLength={64}
/>
{!isLicenseFlow && (
<div className="space-y-2">
<Input
placeholder={t`your-workspace`}
value={isProToggle ? slug : t`your-workspace`}
onChange={(e) => {
setSlugManuallyEdited(true);
setSlug(
e.target.value
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.slice(0, 60),
);
}}
disabled={!isProToggle}
prefix="kan.bn/"
className={
!isProToggle ? "cursor-not-allowed opacity-50" : ""
}
errorMessage={slugError}
iconRight={
!isProToggle ? (
<Tooltip
content={
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
}
placement="top"
delay={0}
>
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
</Tooltip>
) : isProToggle && slug.length >= 3 ? (
isTyping || slugAvailability.isPending ? (
<LoadingSpinner />
) : isSlugAvailable ? (
<HiCheck className="h-4 w-4 text-white" />
) : null
) : null
}
/>
</div>
)}
{!isLicenseFlow && plan !== "pro" && (
<div className="pb-2">
<Toggle
isChecked={isProToggle}
onChange={() => setIsProToggle((v) => !v)}
label={t`Upgrade to Pro ($29/month)`}
labelPosition="after"
/>
</div>
)}
<div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t`Workspace description`}
maxLength={280}
rows={3}
className="block w-full resize-none rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6"
/>
<p className="mt-1 text-right text-[10px] text-light-700 dark:text-dark-700">
{description.length}/280
</p>
</div>
</div>
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
<div className="ml-auto flex gap-2">
{!isLicenseFlow && (
<Button
variant="ghost"
onClick={() =>
router.replace(
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
)
}
>
{t`Back`}
</Button>
)}
<Button
onClick={() => void handleContinue()}
disabled={
!name.trim() ||
createWorkspace.isPending ||
isRedirectingToCheckout ||
(isProToggle &&
slug.length >= 3 &&
(isTyping || slugAvailability.isPending || !!slugError))
}
isLoading={
createWorkspace.isPending || isRedirectingToCheckout
}
>
{t`Continue`}
</Button>
</div>
</div>
</div>
{/* Right panel — browser mockup */}
<div className="hidden flex-col bg-light-300 dark:bg-dark-200 md:flex md:w-[45%]">
{/* Browser chrome */}
<div className="flex items-center gap-2 border-b border-light-400 px-3 py-2.5 dark:border-dark-400">
<div className="flex items-center gap-1.5 text-light-700 dark:text-dark-700">
<HiArrowLeft className="h-3.5 w-3.5" />
<HiArrowRight className="h-3.5 w-3.5" />
<HiArrowPath className="h-3.5 w-3.5" />
</div>
<div className="flex flex-1 items-center gap-1.5 rounded-md bg-light-200 px-2.5 py-1 dark:bg-dark-300">
<HiLockClosed className="h-3 w-3 flex-shrink-0 text-light-700 dark:text-dark-700" />
<span className="truncate text-xs text-light-900 dark:text-dark-900">
kan.bn/
<span className="text-light-1000 dark:text-dark-1000">
{previewSlug}
</span>
</span>
</div>
<HiEllipsisVertical className="h-4 w-4 flex-shrink-0 text-light-700 dark:text-dark-700" />
</div>
{/* Browser content */}
<div className="flex flex-1 flex-col items-center overflow-hidden px-14 py-8">
{/* Workspace name + description */}
<p className="text-center text-xs font-bold text-light-1000 dark:text-dark-1000">
{name || t`Your workspace`}
</p>
<p className="mt-0.5 line-clamp-2 break-all text-center text-[10px] text-light-800 dark:text-dark-800">
{description || t`Your workspace description`}
</p>
{/* Boards area */}
<div className="mt-3 flex w-full flex-1 flex-col rounded-lg bg-light-200 p-2 dark:bg-dark-300">
<div className="flex flex-1 flex-col gap-1.5">
{BOARDS.map((board, i) => (
<motion.div
key={board}
initial={{ opacity: 0, y: -12 }}
animate={
i < visibleCount
? { opacity: 1, y: 0 }
: { opacity: 0, y: -12 }
}
transition={{
opacity: { duration: 0.2 },
y: { duration: 0.25, ease: "easeOut" },
}}
className="flex w-full flex-1 items-center justify-center rounded border border-dashed border-light-400 bg-light-50 px-3 text-[10px] font-medium text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900"
>
{board}
</motion.div>
))}
</div>
</div>
{/* Footer */}
<p className="mt-2 text-[10px] font-semibold text-light-900 dark:text-dark-900">
kan.bn
</p>
</div>
</div>
</div>
</div>
{!hasExistingWorkspace && (
<Button variant="ghost" onClick={() => authClient.signOut()}>
{t`Sign out`}
</Button>
)}
</div>
);
}

View File

@@ -140,6 +140,11 @@ export function CardModal({
</div>
) : (
<>
{data?.cardNumber != null && data.list.board.workspace.cardPrefix && (
<span className="mb-1 block text-xs font-medium text-light-700 dark:text-dark-800">
{data.list.board.workspace.cardPrefix}-{data.cardNumber}
</span>
)}
<h1 className="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{data?.title}
</h1>

View File

@@ -85,17 +85,19 @@ export default function AccountSettings() {
{isCredentialsEnabled && (
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Change Password`}
{data?.hasPassword ? t`Change Password` : t`Set Password`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`You are about to change your password.`}
{data?.hasPassword
? t`You are about to change your password.`
: t`Set a password to enable password-based login.`}
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("CHANGE_PASSWORD")}
>
{t`Change Password`}
{data?.hasPassword ? t`Change Password` : t`Set Password`}
</Button>
</div>
</div>
@@ -113,7 +115,7 @@ export default function AccountSettings() {
modalSize="sm"
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
>
<ChangePasswordFormConfirmation />
<ChangePasswordFormConfirmation hasPassword={data?.hasPassword ?? false} />
</Modal>
{/* Global modals */}

View File

@@ -1,4 +1,6 @@
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button";
@@ -7,24 +9,49 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export default function BillingSettings() {
const { modalContentType, isOpen } = useModal();
const router = useRouter();
const isCloud = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
const { workspace } = useWorkspace();
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
);
const subscription = workspaceData?.subscriptions.find((s) =>
["active", "trialing", "past_due"].includes(s.status),
);
const planLabel = (() => {
if (!subscription) return t`Free (1 member)`;
if (subscription.unlimitedSeats) {
const name =
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
return `${name} (${t`unlimited members`})`;
}
if (subscription.seats != null) {
const name =
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
return `${name} (${subscription.seats} ${subscription.seats === 1 ? t`member` : t`members`})`;
}
return (
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1)
);
})();
const handleOpenBillingPortal = async () => {
try {
const response = await fetch("/api/stripe/create_billing_session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
});
const { url } = (await response.json()) as { url: string };
if (url) {
window.location.href = url;
}
if (url) window.location.href = url;
} catch (error) {
console.error("Error creating billing session:", error);
}
@@ -34,6 +61,28 @@ export default function BillingSettings() {
<>
<PageHead title={t`Settings | Billing`} />
<div className="border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Plan`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{planLabel}
</p>
{!subscription && isCloud && (
<div className="mb-8">
<Button
onClick={() =>
router.push(
`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`,
)
}
>
{t`Choose plan`}
</Button>
</div>
)}
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Billing`}

View File

@@ -1,11 +1,6 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { HiBolt } from "react-icons/hi2";
import type { Subscription } from "@kan/shared/utils";
import { hasActiveSubscription } from "@kan/shared/utils";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
@@ -17,43 +12,21 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
export default function WorkspaceSettings() {
const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace();
const { canEditWorkspace } = usePermissions();
const router = useRouter();
const { data } = api.user.getUser.useQuery();
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
);
const subscriptions = workspaceData?.subscriptions as
| Subscription[]
| undefined;
// 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") &&
!hasOpenedUpgradeModal
) {
openModal("UPGRADE_TO_PRO");
setHasOpenedUpgradeModal(true);
}
}, [router.query.upgrade, subscriptions, openModal, hasOpenedUpgradeModal]);
return (
<>
<PageHead title={t`Settings | Workspace`} />
@@ -107,19 +80,6 @@ export default function WorkspaceSettings() {
disabled={!canEditWorkspace}
/>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasActiveSubscription(subscriptions, "pro") &&
!hasActiveSubscription(subscriptions, "team") && (
<div className="my-8">
<Button
onClick={() => openModal("UPGRADE_TO_PRO")}
iconRight={<HiBolt />}
>
{t`Upgrade to Pro`}
</Button>
</div>
)}
<div className="border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Delete workspace`}
@@ -146,16 +106,6 @@ export default function WorkspaceSettings() {
>
<DeleteWorkspaceConfirmation />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"}
>
<UpgradeToProConfirmation
userId={data?.id ?? ""}
workspacePublicId={workspace.publicId}
/>
</Modal>
{/* Global modals */}
<Modal
modalSize="md"

View File

@@ -110,18 +110,17 @@ export default function Avatar({
const cropYpx = (crop.y / 100) * image.naturalHeight;
const cropWpx = (crop.width / 100) * image.naturalWidth;
const cropHpx = (crop.height / 100) * image.naturalHeight;
canvas.width = Math.max(1, Math.floor(cropWpx));
canvas.height = Math.max(1, Math.floor(cropHpx));
// Cap output at 512x512 — avatars display at 64x64, so higher res is wasteful
// and causes "File too large" errors on HiDPI screens
const maxSize = 512;
const scale = Math.min(maxSize / cropWpx, maxSize / cropHpx, 1);
canvas.width = Math.max(1, Math.floor(cropWpx * scale));
canvas.height = Math.max(1, Math.floor(cropHpx * scale));
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas not supported");
// For better quality on HiDPI screens
const pixelRatio = window.devicePixelRatio || 1;
canvas.width = canvas.width * pixelRatio;
canvas.height = canvas.height * pixelRatio;
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.imageSmoothingQuality = "high";
ctx.drawImage(
image,
cropXpx,
@@ -130,8 +129,8 @@ export default function Avatar({
cropHpx,
0,
0,
canvas.width / pixelRatio,
canvas.height / pixelRatio,
canvas.width,
canvas.height,
);
const mime = selectedFile?.type ?? "image/jpeg";
@@ -139,6 +138,7 @@ export default function Avatar({
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error("toBlob failed"))),
mime,
0.85,
);
});
return blob;

View File

@@ -2,7 +2,6 @@ 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";
@@ -14,27 +13,43 @@ 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`),
const buildSchema = (hasPassword: boolean) => {
const base = z.object({
currentPassword: hasPassword
? z.string().min(1, t`Current password is required`)
: z.string().optional(),
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>;
return base
.refine((data) => data.newPassword === data.confirmPassword, {
message: t`Passwords do not match`,
path: ["confirmPassword"],
})
.refine(
(data) => !hasPassword || data.currentPassword !== data.newPassword,
{
message: t`New password must be different from current password`,
path: ["newPassword"],
},
);
};
export function ChangePasswordFormConfirmation() {
interface FormValues {
currentPassword?: string;
newPassword: string;
confirmPassword: string;
}
interface Props {
hasPassword: boolean;
}
export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const router = useRouter();
@@ -47,15 +62,24 @@ export function ChangePasswordFormConfirmation() {
reset,
setError,
} = useForm<FormValues>({
resolver: zodResolver(FormSchema),
resolver: zodResolver(buildSchema(hasPassword)),
mode: "onChange",
});
const setPasswordMutation = api.user.setPassword.useMutation();
const changePasswordMutation = useMutation({
mutationFn: async (data: FormValues) => {
if (!hasPassword) {
await setPasswordMutation.mutateAsync({
newPassword: data.newPassword,
});
return;
}
const response = await authClient.changePassword({
newPassword: data.newPassword,
currentPassword: data.currentPassword,
currentPassword: data.currentPassword ?? "",
revokeOtherSessions: true,
});
@@ -66,12 +90,18 @@ export function ChangePasswordFormConfirmation() {
onSuccess: async () => {
closeModal();
showPopup({
header: t`Password Changed`,
message: t`Your password has been changed.`,
header: hasPassword ? t`Password Changed` : t`Password Set`,
message: hasPassword
? t`Your password has been changed.`
: t`Your password has been set.`,
icon: "success",
});
utils.invalidate();
if (!hasPassword) {
sessionStorage.removeItem("set_password_prompted");
}
utils.user.getUser.invalidate();
reset();
router.push("/");
},
@@ -86,7 +116,9 @@ export function ChangePasswordFormConfirmation() {
} else {
closeModal();
showPopup({
header: t`Error Changing Password`,
header: hasPassword
? t`Error Changing Password`
: t`Error Setting Password`,
message: t`An unexpected error occurred. Please try again later.`,
icon: "error",
});
@@ -106,27 +138,33 @@ export function ChangePasswordFormConfirmation() {
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>
<h2 className="pb-4 text-base font-medium dark:text-white">
{hasPassword ? t`Change Password` : t`Set Password`}
</h2>
<p className="mb-4 text-sm text-light-900">
{t`Enter your current password and choose a new secure password.`}
{hasPassword
? t`Enter your current password and choose a new secure password.`
: t`You signed in without a password. Set a password to enable password-based login.`}
</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>
{hasPassword && (
<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
@@ -175,7 +213,7 @@ export function ChangePasswordFormConfirmation() {
fullWidth
size="lg"
>
{t`Change Password`}
{hasPassword ? t`Change Password` : t`Set Password`}
</Button>
</div>
</form>

View File

@@ -13,20 +13,29 @@ interface DeleteWebhookConfirmationProps {
export function DeleteWebhookConfirmation({
workspacePublicId,
}: DeleteWebhookConfirmationProps) {
const { closeModal, entityId: webhookPublicId, entityLabel: webhookName } = useModal();
const {
closeModal,
entityId: webhookPublicId,
entityLabel: webhookName,
} = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const deleteWebhookMutation = api.webhook.delete.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook deleted successfully`, type: "success" });
showPopup({
header: t`Webhook deleted`,
message: t`Webhook deleted successfully`,
icon: "success",
});
closeModal();
},
onError: (error) => {
showPopup({
header: t`Unable to delete webhook`,
message: error.message || t`Failed to delete webhook`,
type: "error",
icon: "error",
});
},
});
@@ -35,7 +44,7 @@ export function DeleteWebhookConfirmation({
if (!webhookPublicId) return;
deleteWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
webhookPublicId: webhookPublicId,
});
};

View File

@@ -44,7 +44,12 @@ export function NewWebhookModal({
workspacePublicId,
isEdit = false,
}: NewWebhookModalProps) {
const { closeModal, entityId: webhookPublicId, getModalState, clearModalState } = useModal();
const {
closeModal,
entityId: webhookPublicId,
getModalState,
clearModalState,
} = useModal();
const { showPopup } = usePopup();
const [isTestingWebhook, setIsTestingWebhook] = useState(false);
@@ -101,13 +106,18 @@ export function NewWebhookModal({
const createWebhookMutation = api.webhook.create.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook created successfully`, type: "success" });
showPopup({
header: t`Webhook created`,
message: t`Webhook created successfully`,
icon: "success",
});
closeModal();
},
onError: (error) => {
showPopup({
header: t`Unable to create webhook`,
message: error.message || t`Failed to create webhook`,
type: "error",
icon: "error",
});
},
});
@@ -115,13 +125,18 @@ export function NewWebhookModal({
const updateWebhookMutation = api.webhook.update.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook updated successfully`, type: "success" });
showPopup({
header: t`Webhook updated`,
message: t`Webhook updated successfully`,
icon: "success",
});
closeModal();
},
onError: (error) => {
showPopup({
header: t`Unable to update webhook`,
message: error.message || t`Failed to update webhook`,
type: "error",
icon: "error",
});
},
});
@@ -129,19 +144,25 @@ export function NewWebhookModal({
const testWebhookMutation = api.webhook.test.useMutation({
onSuccess: (result) => {
if (result.success) {
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
showPopup({
header: t`Test sent`,
message: t`Test webhook sent successfully!`,
icon: "success",
});
} else {
showPopup({
header: t`Test failed`,
message: result.error || t`Webhook test failed`,
type: "error",
icon: "error",
});
}
setIsTestingWebhook(false);
},
onError: (error) => {
showPopup({
header: t`Unable to test webhook`,
message: error.message || t`Failed to test webhook`,
type: "error",
icon: "error",
});
setIsTestingWebhook(false);
},
@@ -151,7 +172,7 @@ export function NewWebhookModal({
if (isEdit && webhookPublicId) {
updateWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
webhookPublicId: webhookPublicId,
name: data.name,
url: data.url,
secret: data.secret || undefined,
@@ -174,11 +195,12 @@ export function NewWebhookModal({
setIsTestingWebhook(true);
testWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
webhookPublicId: webhookPublicId,
});
};
const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending;
const isPending =
createWebhookMutation.isPending || updateWebhookMutation.isPending;
return (
<form onSubmit={handleSubmit(onSubmit)}>
@@ -231,7 +253,11 @@ export function NewWebhookModal({
<Input
id="secret"
type="password"
placeholder={isEdit ? t`Enter new secret to update` : t`HMAC secret for signature verification`}
placeholder={
isEdit
? t`Enter new secret to update`
: t`HMAC secret for signature verification`
}
{...register("secret")}
errorMessage={errors.secret?.message}
/>
@@ -252,7 +278,7 @@ export function NewWebhookModal({
{webhookEvents.map((event) => (
<label
key={event}
className="flex items-center space-x-2 cursor-pointer"
className="flex cursor-pointer items-center space-x-2"
>
<input
type="checkbox"
@@ -262,11 +288,11 @@ export function NewWebhookModal({
field.onChange([...field.value, event]);
} else {
field.onChange(
field.value.filter((v) => v !== event)
field.value.filter((v) => v !== event),
);
}
}}
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 dark:border-dark-400"
/>
<span className="text-sm text-light-900 dark:text-dark-900">
{event}
@@ -277,17 +303,19 @@ export function NewWebhookModal({
)}
/>
{errors.events && (
<p className="mt-1 text-xs text-red-500">{errors.events.message}</p>
<p className="mt-1 text-xs text-red-500">
{errors.events.message}
</p>
)}
</div>
{isEdit && (
<div>
<label className="flex items-center space-x-2 cursor-pointer">
<label className="flex cursor-pointer items-center space-x-2">
<input
type="checkbox"
{...register("active")}
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 dark:border-dark-400"
/>
<span className="text-sm text-light-900 dark:text-dark-900">
{t`Active`}

View File

@@ -36,7 +36,7 @@ export default function UpdateWeekStartDayForm({
setValue(newValue);
updateWorkspace.mutate({
workspacePublicId,
weekStartDay: newValue,
weekStartDay: newValue as 0 | 1 | 6,
});
};

View File

@@ -20,7 +20,7 @@ const UpdateWorkspaceUrlForm = ({
}: {
workspacePublicId: string;
workspaceUrl: string;
workspacePlan: "free" | "pro" | "enterprise";
workspacePlan: "free" | "team" | "pro" | "enterprise";
disabled?: boolean;
}) => {
const utils = api.useUtils();

View File

@@ -24,6 +24,8 @@ export function UpgradeToProConfirmation({
body: JSON.stringify({
...(entityId && { slug: entityId }),
workspacePublicId: workspacePublicId,
plan: "pro",
billing: "monthly",
cancelUrl: "/settings",
successUrl: "/settings",
}),

View File

@@ -171,18 +171,20 @@ export default function WebhookList({ workspacePublicId }: WebhookListProps) {
const testWebhookMutation = api.webhook.test.useMutation({
onSuccess: (result) => {
if (result.success) {
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
showPopup({ header: t`Test sent`, message: t`Test webhook sent successfully!`, icon: "success" });
} else {
showPopup({
header: t`Test failed`,
message: result.error || t`Webhook test failed`,
type: "error",
icon: "error",
});
}
},
onError: (error) => {
showPopup({
header: t`Unable to test webhook`,
message: error.message || t`Failed to test webhook`,
type: "error",
icon: "error",
});
},
});

View File

@@ -99,6 +99,19 @@ services:
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
# Partner config (optional)
- NEXT_PUBLIC_PARTNER_NAME=${NEXT_PUBLIC_PARTNER_NAME}
- PARTNER_CLIENT_ID=${PARTNER_CLIENT_ID}
- PARTNER_CLIENT_SECRET=${PARTNER_CLIENT_SECRET}
- PARTNER_REDIRECT_URL=${PARTNER_REDIRECT_URL}
- PARTNER_TOKEN_URL=${PARTNER_TOKEN_URL}
- PARTNER_OAUTH_LICENSE_URL=${PARTNER_OAUTH_LICENSE_URL}
- PARTNER_LICENSE_API_URL=${PARTNER_LICENSE_API_URL}
- PARTNER_API_KEY=${PARTNER_API_KEY}
- PARTNER_API_KEY_HEADER=${PARTNER_API_KEY_HEADER}
- PARTNER_SIGNATURE_HEADER=${PARTNER_SIGNATURE_HEADER}
- PARTNER_TIMESTAMP_HEADER=${PARTNER_TIMESTAMP_HEADER}
depends_on:
migrator:
condition: service_completed_successfully

View File

@@ -61,7 +61,7 @@ services:
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_STORAGE_URL
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}

View File

@@ -5,31 +5,35 @@
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./root": {
"types": "./dist/root.d.ts",
"types": "./src/root.ts",
"default": "./src/root.ts"
},
"./trpc": {
"types": "./dist/trpc.d.ts",
"types": "./src/trpc.ts",
"default": "./src/trpc.ts"
},
"./types": {
"types": "./dist/types.d.ts",
"types": "./src/types/router.types.ts",
"default": "./src/types/router.types.ts"
},
"./openapi": {
"types": "./dist/openapi.d.ts",
"types": "./src/openapi.ts",
"default": "./src/openapi.ts"
},
"./utils/rateLimit": {
"types": "./dist/utils/rateLimit.d.ts",
"types": "./src/utils/rateLimit.ts",
"default": "./src/utils/rateLimit.ts"
},
"./utils/apiLogging": {
"types": "./src/utils/apiLogging.ts",
"default": "./src/utils/apiLogging.ts"
},
"./utils/permissions": {
"types": "./dist/utils/permissions.d.ts",
"types": "./src/utils/permissions.ts",
"default": "./src/utils/permissions.ts"
}
},

View File

@@ -8,6 +8,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { attachmentConfirmResponseSchema } from "../schemas";
import { assertPermission } from "../utils/permissions";
import { deleteObject, generateUploadUrl } from "@kan/shared/utils";
@@ -110,7 +111,7 @@ export const attachmentRouter = createTRPCRouter({
size: z.number().positive(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardAttachmentRepo.create>>>())
.output(attachmentConfirmResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

@@ -16,6 +16,13 @@ import {
} from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
boardListItemSchema,
boardDetailSchema,
boardBySlugSchema,
boardCreateResponseSchema,
boardUpdateResponseSchema,
} from "../schemas";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
export const boardRouter = createTRPCRouter({
@@ -37,9 +44,7 @@ export const boardRouter = createTRPCRouter({
archived: z.boolean().optional(),
}),
)
.output(
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
)
.output(z.array(boardListItemSchema))
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -106,7 +111,7 @@ export const boardRouter = createTRPCRouter({
type: z.enum(["regular", "template"]).optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
.output(boardDetailSchema)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -246,7 +251,7 @@ export const boardRouter = createTRPCRouter({
.optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>())
.output(boardBySlugSchema.nullable())
.query(async ({ ctx, input }) => {
const workspace = await workspaceRepo.getBySlugWithBoards(
ctx.db,
@@ -299,7 +304,7 @@ export const boardRouter = createTRPCRouter({
sourceBoardPublicId: z.string().min(12).optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
.output(boardCreateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -469,7 +474,7 @@ export const boardRouter = createTRPCRouter({
isArchived: z.boolean().optional(),
}),
)
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
.output(boardUpdateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

@@ -4,11 +4,20 @@ import { z } from "zod";
import * as cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as cardCommentRepo from "@kan/db/repository/cardComment.repo";
import * as checklistRepo from "@kan/db/repository/checklist.repo";
import * as labelRepo from "@kan/db/repository/label.repo";
import * as listRepo from "@kan/db/repository/list.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
cardCreateResponseSchema,
cardUpdateResponseSchema,
cardDetailSchema,
commentResponseSchema,
commentDeleteResponseSchema,
activityItemSchema,
} from "../schemas";
import { mergeActivities } from "../utils/activities";
import { sendMentionEmails } from "../utils/notifications";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
@@ -41,7 +50,7 @@ export const cardRouter = createTRPCRouter({
dueDate: z.date().nullable().optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardRepo.create>>>())
.output(cardCreateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -69,6 +78,7 @@ export const cardRouter = createTRPCRouter({
description: input.description,
createdBy: userId,
listId: list.id,
workspaceId: list.workspaceId,
position: input.position,
dueDate: input.dueDate ?? null,
});
@@ -177,10 +187,11 @@ export const cardRouter = createTRPCRouter({
"card.created",
{
id: String(newCard.id),
publicId: newCard.publicId,
title: input.title,
description: input.description,
dueDate: input.dueDate ?? null,
listId: String(newCard.listId),
listId: list.publicId,
},
{
boardId: list.boardPublicId,
@@ -214,7 +225,7 @@ export const cardRouter = createTRPCRouter({
comment: z.string().min(1),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.create>>>())
.output(commentResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -287,7 +298,7 @@ export const cardRouter = createTRPCRouter({
comment: z.string().min(1),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.update>>>())
.output(commentResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -375,7 +386,7 @@ export const cardRouter = createTRPCRouter({
commentPublicId: z.string().min(12),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.softDelete>>>())
.output(commentDeleteResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -434,7 +445,7 @@ export const cardRouter = createTRPCRouter({
createdBy: userId,
});
return deletedComment;
return { publicId: input.commentPublicId };
}),
addOrRemoveLabel: protectedProcedure
.meta({
@@ -638,25 +649,7 @@ export const cardRouter = createTRPCRouter({
},
})
.input(z.object({ cardPublicId: z.string().min(12) }))
.output(
z.custom<
Omit<
NonNullable<
Awaited<ReturnType<typeof cardRepo.getWithListAndMembersByPublicId>>
>,
"attachments"
> & {
attachments: {
publicId: string;
contentType: string;
s3Key: string;
originalFilename: string | null;
size?: number | null;
url: string | null;
}[];
}
>(),
)
.output(cardDetailSchema)
.query(async ({ ctx, input }) => {
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
ctx.db,
@@ -762,15 +755,7 @@ export const cardRouter = createTRPCRouter({
)
.output(
z.object({
activities: z.array(
z.custom<
NonNullable<
Awaited<
ReturnType<typeof cardActivityRepo.getPaginatedActivities>
>
>["activities"][number]
>(),
),
activities: z.array(activityItemSchema),
hasMore: z.boolean(),
nextCursor: z.string().datetime().nullable(),
}),
@@ -870,7 +855,7 @@ export const cardRouter = createTRPCRouter({
dueDate: z.date().nullable().optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardRepo.update>>>())
.output(cardUpdateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -905,9 +890,18 @@ export const cardRouter = createTRPCRouter({
);
let newListId: number | undefined;
let newList:
| {
id: number;
publicId: string;
name: string;
boardId: number;
index: number;
}
| undefined;
if (input.listPublicId) {
const newList = await listRepo.getByPublicId(
newList = await listRepo.getByPublicId(
ctx.db,
input.listPublicId,
);
@@ -952,7 +946,7 @@ export const cardRouter = createTRPCRouter({
);
}
if (input.index !== undefined) {
if (input.index !== undefined || newListId !== undefined) {
result = await cardRepo.reorder(ctx.db, {
cardId: existingCard.id,
newIndex: input.index,
@@ -1054,8 +1048,19 @@ export const cardRouter = createTRPCRouter({
) {
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
}
if (newListId && existingCard.listId !== newListId) {
webhookChanges.listId = { from: existingCard.listId, to: newListId };
const movedToNewList = Boolean(newListId && existingCard.listId !== newListId);
const currentWebhookListPublicId = movedToNewList
? input.listPublicId!
: existingCard.list.publicId;
const currentWebhookListName = movedToNewList
? newList?.name ?? card.listName
: existingCard.list.name;
if (movedToNewList) {
webhookChanges.listId = {
from: existingCard.list.publicId,
to: input.listPublicId!,
};
}
// Fire webhooks (non-blocking)
@@ -1063,20 +1068,19 @@ export const cardRouter = createTRPCRouter({
ctx.db,
card.workspaceId,
createCardWebhookPayload(
newListId && existingCard.listId !== newListId
? "card.moved"
: "card.updated",
movedToNewList ? "card.moved" : "card.updated",
{
id: String(result.id),
publicId: result.publicId,
title: result.title,
description: result.description,
dueDate: result.dueDate,
listId: String(newListId ?? existingCard.listId),
listId: currentWebhookListPublicId,
},
{
boardId: card.boardPublicId,
boardName: card.boardName,
listName: card.listName,
listName: currentWebhookListName,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,
@@ -1163,15 +1167,16 @@ export const cardRouter = createTRPCRouter({
"card.deleted",
{
id: String(fullCard.id),
publicId: fullCard.publicId,
title: fullCard.title,
description: fullCard.description,
dueDate: fullCard.dueDate,
listId: String(fullCard.listId),
listId: fullCard.list.publicId,
},
{
boardId: card.boardPublicId,
boardName: card.boardName,
listName: card.listName,
listName: fullCard.list.name,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,
@@ -1184,4 +1189,177 @@ export const cardRouter = createTRPCRouter({
return { success: true };
}),
duplicate: protectedProcedure
.meta({
openapi: {
summary: "Duplicate a card",
method: "POST",
path: "/cards/{cardPublicId}/duplicate",
description: "Duplicates a card to a target list with optional options",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
cardPublicId: z.string().min(12),
listPublicId: z.string().min(12),
index: z.number().int().min(0).optional(),
title: z.string().min(1).max(2000).optional(),
copyLabels: z.boolean(),
copyMembers: z.boolean(),
copyChecklists: z.boolean(),
}),
)
.output(
z.object({
publicId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const sourceCardMeta = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
ctx.db,
input.cardPublicId,
);
if (!sourceCardMeta)
throw new TRPCError({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});
await assertPermission(
ctx.db,
userId,
sourceCardMeta.workspaceId,
"card:create",
);
const targetList = await listRepo.getWorkspaceAndListIdByListPublicId(
ctx.db,
input.listPublicId,
);
if (!targetList)
throw new TRPCError({
message: `List with public ID ${input.listPublicId} not found`,
code: "NOT_FOUND",
});
if (targetList.workspaceId !== sourceCardMeta.workspaceId)
throw new TRPCError({
message: `Target list must be in the same workspace`,
code: "BAD_REQUEST",
});
const sourceCard = await cardRepo.getWithListAndMembersByPublicId(
ctx.db,
input.cardPublicId,
);
if (!sourceCard)
throw new TRPCError({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});
const newCard = await cardRepo.create(ctx.db, {
title: input.title ?? sourceCard.title,
description: sourceCard.description ?? "",
createdBy: userId,
listId: targetList.id,
workspaceId: targetList.workspaceId,
position: "end",
dueDate: sourceCard.dueDate ?? null,
});
if (input.index !== undefined && input.index >= 0) {
await cardRepo.reorder(ctx.db, {
cardId: newCard.id,
newIndex: input.index,
newListId: targetList.id,
});
}
if (input.copyLabels && sourceCard.labels?.length) {
const labelPublicIds = sourceCard.labels.map((l) => l.publicId);
const labels = await labelRepo.getAllByPublicIds(ctx.db, labelPublicIds);
if (labels.length) {
const labelsInsert = labels.map((label) => ({
cardId: newCard.id,
labelId: label.id,
}));
await cardRepo.bulkCreateCardLabelRelationships(ctx.db, labelsInsert);
const cardActivitesInsert = labels.map((cardLabel) => ({
type: "card.updated.label.added" as const,
cardId: newCard.id,
labelId: cardLabel.id,
createdBy: userId,
}));
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
}
}
if (input.copyMembers && sourceCard.members?.length) {
const memberPublicIds = sourceCard.members.map((m) => m.publicId);
const members = await workspaceRepo.getAllMembersByPublicIds(
ctx.db,
memberPublicIds,
);
if (members.length) {
const membersInsert = members.map((member) => ({
cardId: newCard.id,
workspaceMemberId: member.id,
}));
await cardRepo.bulkCreateCardWorkspaceMemberRelationships(
ctx.db,
membersInsert,
);
const cardActivitesInsert = members.map((member) => ({
type: "card.updated.member.added" as const,
cardId: newCard.id,
workspaceMemberId: member.id,
createdBy: userId,
}));
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
}
}
if (input.copyChecklists && sourceCard.checklists?.length) {
for (const checklist of sourceCard.checklists) {
const newChecklist = await checklistRepo.create(ctx.db, {
cardId: newCard.id,
name: checklist.name,
createdBy: userId,
});
if (!newChecklist?.id) continue;
if (checklist.items?.length) {
for (const item of checklist.items) {
await checklistRepo.createItem(ctx.db, {
checklistId: newChecklist.id,
title: item.title,
createdBy: userId,
completed: item.completed ?? false,
});
}
}
await cardActivityRepo.create(ctx.db, {
type: "card.updated.checklist.added",
cardId: newCard.id,
toTitle: newChecklist.name,
createdBy: userId,
});
}
}
return { publicId: newCard.publicId };
}),
});

View File

@@ -340,6 +340,7 @@ export const importRouter = createTRPCRouter({
description: card.description,
createdBy: userId,
listId: newListId,
workspaceId: workspace.id,
index,
importId: newImportId,
}));
@@ -888,6 +889,7 @@ export const importRouter = createTRPCRouter({
description: data.description,
createdBy: userId,
listId: data.listId,
workspaceId: workspace.id,
index: index,
importId: newImportId,
}));

View File

@@ -7,6 +7,7 @@ import * as activityRepo from "@kan/db/repository/cardActivity.repo";
import * as listRepo from "@kan/db/repository/list.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { listCreateResponseSchema, listUpdateResponseSchema } from "../schemas";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
export const listRouter = createTRPCRouter({
@@ -27,7 +28,7 @@ export const listRouter = createTRPCRouter({
boardPublicId: z.string().min(12),
}),
)
.output(z.custom<Awaited<ReturnType<typeof listRepo.create>>>())
.output(listCreateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -163,12 +164,7 @@ export const listRouter = createTRPCRouter({
index: z.number().optional(),
}),
)
.output(
z.custom<
| Awaited<ReturnType<typeof listRepo.update>>
| Awaited<ReturnType<typeof listRepo.reorder>>
>(),
)
.output(listUpdateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

@@ -16,6 +16,7 @@ import {
import { updateSubscriptionSeats } from "@kan/stripe";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { memberInviteResponseSchema } from "../schemas";
import {
assertCanManageMember,
assertCanManageRole,
@@ -40,7 +41,7 @@ export const memberRouter = createTRPCRouter({
workspacePublicId: z.string().min(12),
}),
)
.output(z.custom<Awaited<ReturnType<typeof memberRepo.create>>>())
.output(memberInviteResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

@@ -2,9 +2,9 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as userRepo from "@kan/db/repository/user.repo";
import { generateAvatarUrl } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { generateAvatarUrl } from "@kan/shared/utils";
export const userRouter = createTRPCRouter({
getUser: protectedProcedure
@@ -27,6 +27,8 @@ export const userRouter = createTRPCRouter({
name: z.string().nullable(),
image: z.string().nullable(),
stripeCustomerId: z.string().nullable(),
hasPassword: z.boolean(),
hasMagicLinkAccount: z.boolean(),
apiKey: z
.object({
id: z.number(),
@@ -61,6 +63,8 @@ export const userRouter = createTRPCRouter({
return {
...result,
image: imageUrl,
hasPassword: result.hasPassword,
hasMagicLinkAccount: result.hasMagicLinkAccount,
apiKey: apiKey ? { id: apiKey.id, prefix: apiKey.prefix } : null,
};
}),
@@ -114,4 +118,60 @@ export const userRouter = createTRPCRouter({
image: imageUrl,
};
}),
setPassword: protectedProcedure
.meta({
openapi: {
method: "POST",
path: "/users/me/password",
summary: "Set password",
description:
"Sets a password for a user who signed up via magic link and has no password yet",
tags: ["Users"],
protect: true,
},
})
.input(
z.object({
newPassword: z
.string()
.min(8, "Password must be at least 8 characters"),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const existing = await userRepo.getById(ctx.db, userId);
if (!existing) {
throw new TRPCError({
message: `User not found`,
code: "NOT_FOUND",
});
}
if (existing.hasPassword) {
throw new TRPCError({
message: `Password already set; use change password instead`,
code: "BAD_REQUEST",
});
}
try {
await ctx.auth.api.setPassword({ newPassword: input.newPassword });
} catch {
throw new TRPCError({
message: "Failed to set password",
code: "INTERNAL_SERVER_ERROR",
});
}
return { success: true };
}),
});

View File

@@ -334,6 +334,7 @@ export const webhookRouter = createTRPCRouter({
const testPayload = createCardWebhookPayload("card.created", {
id: "test-card-id",
publicId: "test-card-public-id",
title: "Test Card",
description: "This is a test webhook payload",
dueDate: null,

View File

@@ -7,6 +7,14 @@ import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
workspaceListItemSchema,
workspaceDetailSchema,
workspaceWithBoardsSchema,
workspaceCreateResponseSchema,
workspaceUpdateResponseSchema,
workspaceDeleteResponseSchema,
} from "../schemas";
import { assertPermission } from "../utils/permissions";
export const workspaceRouter = createTRPCRouter({
@@ -22,9 +30,7 @@ export const workspaceRouter = createTRPCRouter({
},
})
.input(z.void())
.output(
z.custom<Awaited<ReturnType<typeof workspaceRepo.getAllByUserId>>>(),
)
.output(z.array(workspaceListItemSchema))
.query(async ({ ctx }) => {
const userId = ctx.user?.id;
@@ -50,11 +56,7 @@ export const workspaceRouter = createTRPCRouter({
},
})
.input(z.object({ workspacePublicId: z.string().min(12) }))
.output(
z.custom<
Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>
>(),
)
.output(workspaceDetailSchema)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -134,7 +136,7 @@ export const workspaceRouter = createTRPCRouter({
return {
...result,
members: sanitizedMembers,
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
};
}
return {
@@ -162,9 +164,7 @@ export const workspaceRouter = createTRPCRouter({
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}),
)
.output(
z.custom<Awaited<ReturnType<typeof workspaceRepo.getBySlugWithBoards>>>(),
)
.output(workspaceWithBoardsSchema)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -202,6 +202,7 @@ export const workspaceRouter = createTRPCRouter({
.input(
z.object({
name: z.string().min(1).max(64),
description: z.string().max(280).optional(),
slug: z
.string()
.min(3)
@@ -210,7 +211,7 @@ export const workspaceRouter = createTRPCRouter({
.optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.create>>>())
.output(workspaceCreateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
const userEmail = ctx.user?.email;
@@ -260,6 +261,7 @@ export const workspaceRouter = createTRPCRouter({
slug: workspaceSlug,
createdBy: userId,
createdByEmail: userEmail,
...(input.description && { description: input.description }),
});
if (!result.publicId)
@@ -268,7 +270,14 @@ export const workspaceRouter = createTRPCRouter({
code: "INTERNAL_SERVER_ERROR",
});
return result;
return {
publicId: result.publicId,
name: result.name!,
slug: result.slug!,
description: result.description ?? null,
plan: result.plan!,
cardPrefix: result.cardPrefix!,
};
}),
update: protectedProcedure
.meta({
@@ -298,7 +307,7 @@ export const workspaceRouter = createTRPCRouter({
.optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
.output(workspaceUpdateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -381,7 +390,7 @@ export const workspaceRouter = createTRPCRouter({
},
})
.input(z.object({ workspacePublicId: z.string().min(12) }))
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.hardDelete>>>())
.output(workspaceDeleteResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -403,12 +412,12 @@ export const workspaceRouter = createTRPCRouter({
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
const result = await workspaceRepo.hardDelete(
await workspaceRepo.hardDelete(
ctx.db,
input.workspacePublicId,
);
return result;
return { success: true };
}),
checkSlugAvailability: publicProcedure
.meta({
@@ -513,6 +522,7 @@ export const workspaceRouter = createTRPCRouter({
boardPublicId: z.string(),
boardName: z.string(),
listName: z.string(),
cardNumber: z.number().nullable(),
updatedAt: z.date().nullable(),
createdAt: z.date(),
type: z.literal("card"),

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