Compare commits
1 Commits
fix/hide-e
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47e88532c8 |
@@ -15,4 +15,4 @@ pnpm-debug.log
|
||||
|
||||
README.md
|
||||
.next
|
||||
# .git
|
||||
.git
|
||||
@@ -32,23 +32,15 @@ NEXT_PUBLIC_STORAGE_URL=
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
||||
|
||||
# Auth config (optional)
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||
|
||||
# API configuration (optional)
|
||||
NEXT_API_BODY_SIZE_LIMIT= # e.g. 50mb (defaults to 1mb)
|
||||
|
||||
# Integration providers (optional)
|
||||
TRELLO_APP_API_KEY=
|
||||
TRELLO_APP_SECRET=
|
||||
|
||||
# Redis (optional - for rate limiting)
|
||||
# If not provided, rate limiting will use in-memory storage
|
||||
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
|
||||
|
||||
# OAuth providers (optional)
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
||||
|
||||
@@ -141,7 +141,6 @@ pnpm dev
|
||||
| Variable | Description | Required | Example |
|
||||
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
|
||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
||||
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
|
||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||
@@ -151,7 +150,6 @@ pnpm dev
|
||||
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
|
||||
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
|
||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||
@@ -173,7 +171,6 @@ pnpm dev
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
|
||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||
|
||||
@@ -19,22 +19,19 @@ RUN pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
# 2. Prune projects
|
||||
FROM base AS pruner
|
||||
# https://stackoverflow.com/questions/49681984/how-to-get-version-value-of-package-json-inside-of-dockerfile
|
||||
# RUN export VERSION=$(npm run version)
|
||||
|
||||
ARG PROJECT
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# It might be the path to <ROOT> turborepo
|
||||
COPY . .
|
||||
|
||||
# Generate version from git (fallback if APP_VERSION not provided)
|
||||
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
|
||||
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
|
||||
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
|
||||
sed 's/^v//' || \
|
||||
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
|
||||
echo "unknown") && \
|
||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
||||
|
||||
|
||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||
|
||||
# 3. Build the project
|
||||
@@ -42,22 +39,25 @@ FROM base AS builder
|
||||
ARG PROJECT
|
||||
ARG APP_VERSION
|
||||
|
||||
# Environment to skip .env validation on build
|
||||
ENV CI=true
|
||||
ENV NEXT_PUBLIC_APP_VERSION=${APP_VERSION}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy lockfile and package.json's of isolated subworkspace
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
||||
|
||||
ENV CI=true
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
# Use provided APP_VERSION or auto-generated from pruner stage
|
||||
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
||||
|
||||
RUN pnpm build --filter=${PROJECT}
|
||||
|
||||
# # Copy static files to standalone directory
|
||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 0,
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "ptbr"]
|
||||
},
|
||||
"buckets": {
|
||||
"po": {
|
||||
|
||||
@@ -37,11 +37,8 @@ checksums:
|
||||
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
|
||||
Adding%20a%20new%20member%20will%20cost%20an%20additional%20%7Bprice%7D%20(%7BbillingType%7D)%20per%20seat./singular: 12e88573028306110fbc15ef1e714892
|
||||
Adjust%20the%20square%20crop%20to%20fit%20your%20avatar./singular: a4df26bbce6f14c6962fac1324db00a8
|
||||
Admin/singular: 90eb20f1400db82ab874744e47836dc6
|
||||
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
||||
All%20member%20permission%20overrides%20have%20been%20reset%20to%20their%20role%20defaults./singular: e5c38724a283373506d53afd22b6d096
|
||||
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
|
||||
Allow%20workspace%20members%20to%20see%20each%20other's%20email%20addresses/singular: 0077436d9f37bfd64f3ae076a5a05040
|
||||
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
|
||||
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
|
||||
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
|
||||
@@ -90,31 +87,6 @@ checksums:
|
||||
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
||||
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
||||
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
||||
Can%20add%20comments/singular: d7d70b75780156312701f4c5eed1b285
|
||||
Can%20create%20boards/singular: 8538236f8caafd4eaa751b4ec45f1699
|
||||
Can%20create%20cards/singular: dd1cce2d52e0fb261676750bfc46da22
|
||||
Can%20create%20lists/singular: 53c3c1343efff494640b1227e4444de2
|
||||
Can%20delete%20boards/singular: 818ff50f9f21d8ed3741a7933d66b794
|
||||
Can%20delete%20cards/singular: c62309a7e52958aa4bdb477c07d1855d
|
||||
Can%20delete%20comments/singular: 99884fcaf89c47aa33c6ddfd9f94ea5f
|
||||
Can%20delete%20lists/singular: 8d6ff6cd43c6b9fcbc9fc7954d19409e
|
||||
Can%20delete%20workspace/singular: 41bd5a6636500b1a6e04184e6f566198
|
||||
Can%20edit%20boards/singular: 8b77fabfd955d5507b1826dc4a5c1108
|
||||
Can%20edit%20cards/singular: 47e6c92c1056fd6e8b517ba456b3f607
|
||||
Can%20edit%20comments/singular: a0caeec2b2b64e9f371f07dabed5733d
|
||||
Can%20edit%20lists/singular: 1ce5feb15139c881b615edab065b3e12
|
||||
Can%20edit%20member%20roles%20and%20permissions/singular: c364e8e8866111a471f4081db0730049
|
||||
Can%20edit%20workspace/singular: 9768f990844e215c06910fed250da23a
|
||||
Can%20invite%20members/singular: e02e562bcb7f46008d9595e485951413
|
||||
can%20manage%20workspace%20settings/singular: 78bfe1746f961f37b1cde85e5a0e9a13
|
||||
Can%20manage%20workspace%20settings/singular: 27eb18d3be5b3d813996b7d5071e0317
|
||||
Can%20remove%20members/singular: 97a452b0fb4b661eaca7e5b3d5b49dcd
|
||||
Can%20view%20boards/singular: 14977bbbb566f72fc74e17d99bad09bb
|
||||
Can%20view%20cards/singular: 5e728083853948c9d618f2276732d5cd
|
||||
Can%20view%20comments/singular: 76dbbc9ae4bff589d391c67c84ac6470
|
||||
Can%20view%20lists/singular: e36331c4a49f376befc9f0aa42492b01
|
||||
Can%20view%20members/singular: 5b75a467257a1db29d466c0d9ccea3df
|
||||
Can%20view%20workspace/singular: 79a12c55fcd04ea69cbb85b906794e9b
|
||||
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
|
||||
Card/singular: bba0beaced7ea954ceb980f2b022ffee
|
||||
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
||||
@@ -126,9 +98,6 @@ checksums:
|
||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||
Checklists/singular: 6f79129c8f08ee54d858a2af57d16dd9
|
||||
Clear%20all%20custom%20permissions%3F/singular: 31d0962985c83a29558c98a765bb1b16
|
||||
Clear%20any%20custom%20member%20permissions%20so%20that%20all%20members%20only%20inherit%20permissions%20from%20their%20role%20defaults./singular: e493291818059bfd51f2c87b938616c4
|
||||
Clear%20custom%20permissions/singular: 288ce8688fd09c5a01eda5a6ba4bc98a
|
||||
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
||||
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
||||
Close/singular: 2c2e22f8424a1031de89063bd0022e16
|
||||
@@ -143,7 +112,6 @@ checksums:
|
||||
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
||||
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
||||
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
|
||||
Configure%20which%20actions%20are%20allowed%20for%20each%20workspace%20role.%20These%20permissions%20apply%20to%20all%20members%20with%20that%20role./singular: 0a46a8a30c6c0ebdcd01e72a7d64ec64
|
||||
Confirm%20your%20email%20preferences%3A/singular: 043c161dd9866231ae2418fdd9f61b9c
|
||||
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
|
||||
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
|
||||
@@ -177,7 +145,6 @@ checksums:
|
||||
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
||||
Custom%20board%20templates/singular: c2966b352d76bc53421c01e9c99474bb
|
||||
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
||||
Custom%20permissions/singular: 6f1748601979e2e4548877b292b43a20
|
||||
Custom%20templates/singular: f8caaad67e168f106a298c8e0a66240c
|
||||
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
|
||||
Custom%20workspace%20link/singular: 8a19ae46ccea9c54b65ae183cea70b44
|
||||
@@ -221,13 +188,11 @@ checksums:
|
||||
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
||||
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
||||
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
||||
Edit%20permissions/singular: 244558dd716491b7ed72ba8ab73aa28f
|
||||
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
|
||||
Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b
|
||||
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
|
||||
email/singular: f31eb214738e037d58e26149797739df
|
||||
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
Email%20visibility/singular: 81d41cf573a7109c376d30d905beb596
|
||||
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
|
||||
Enter%20a%20custom%20title/singular: f002074db0bd51d4f28d2736e140370a
|
||||
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
|
||||
@@ -295,7 +260,6 @@ checksums:
|
||||
Go%20to%20members/singular: 445f4efbc4b1e7509f4fd79ebfbb1476
|
||||
Go%20to%20settings/singular: 24a7f96880650c9b37099d69f4b7e2a9
|
||||
Go%20to%20templates/singular: e4e58e33d637282d141df466d729bc7c
|
||||
Guest/singular: 2aec6d6ebe0d9a1db0a5c8cd5a98b8c3
|
||||
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
||||
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
||||
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
|
||||
@@ -357,7 +321,6 @@ checksums:
|
||||
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
||||
Lists/singular: 9f4a73afc8de321175d71935134ef066
|
||||
Load%20more%20activities/singular: f32d40a739ffaa700051c4c7d70055cf
|
||||
Loading%20permissions.../singular: a5665279d4e439186825057c32d4d976
|
||||
Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66
|
||||
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
||||
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
||||
@@ -369,7 +332,6 @@ checksums:
|
||||
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
|
||||
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
|
||||
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
||||
Member/singular: 1606dc30b369856b9dba1fe9aec425d2
|
||||
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
|
||||
Monthly/singular: 818f1192e32bb855597f930d3e78806e
|
||||
@@ -402,9 +364,7 @@ checksums:
|
||||
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
|
||||
No%20keyboard%20shortcuts%20registered./singular: 7f1ed5d777cade7d62303e9e591bbf63
|
||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||
No%20lists%20have%20been%20created%20yet/singular: f18ee3d7230cc33b68bd17b429d1d442
|
||||
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
|
||||
No%20roles%20found%20for%20this%20workspace%20yet./singular: 2eb502333aaf6c58e8ab23d881e2e357
|
||||
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
||||
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
||||
@@ -417,7 +377,6 @@ checksums:
|
||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
|
||||
Overdue/singular: 24caaa2b5d7a2447ab7664e3771cf98c
|
||||
Overrides%20cleared/singular: f2e380efbae31a6113cc0cbf0eadf7b0
|
||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||
@@ -430,10 +389,6 @@ checksums:
|
||||
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
|
||||
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
|
||||
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
||||
Permission/singular: cc2ed7274bd8267f9e0a10b079584d8b
|
||||
Permissions/singular: 2160be68b1d6b6577e64634e9feba2ed
|
||||
Permissions%20reset/singular: dd4776f04aca858deb95887e807570fc
|
||||
Permissions%20updated/singular: 0df44570b783b8b284610da8627d333d
|
||||
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
||||
Planning/singular: 353f58c75248275fe091740607501610
|
||||
Platform/singular: c68862170146325333c7f25af11a3fa2
|
||||
@@ -472,14 +427,12 @@ checksums:
|
||||
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
|
||||
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
|
||||
Research/singular: 3368e9638d1619babd6df9fad592274f
|
||||
Reset%20to%20role%20defaults/singular: fc9ff8ea0503e50da3e9b3e5d8bb75dd
|
||||
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
||||
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
||||
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
|
||||
Review/singular: 299f75db25382980b2895622d7712927
|
||||
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
||||
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
||||
Role%20updated/singular: 73606ae9c35101f1bb518961f68559d0
|
||||
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
|
||||
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
|
||||
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||
@@ -501,7 +454,6 @@ checksums:
|
||||
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
|
||||
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
|
||||
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
|
||||
Settings%20%7C%20Permissions/singular: 8aa60ed978b9f45a705d99dc1ee04f37
|
||||
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
|
||||
Shortcuts/singular: db3330ed3240c398054f3be23c52851f
|
||||
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
||||
@@ -536,8 +488,6 @@ checksums:
|
||||
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
||||
The%20current%20password%20you%20entered%20is%20incorrect./singular: 67a76bf346ab4b48563269e9d941a64a
|
||||
The%20main%20difference%20between%20Kan%20and%20Trello%20is%20that%20Kan%20is%20open%20source%2C%20allowing%20anyone%20to%20view%2C%20modify%2C%20and%20contribute%20to%20our%20code.%20Our%20cloud%20offering%20also%20offers%20no%20restrictions%20on%20features%20for%20individual%20use%2C%20whereas%20Trello%20locks%20basic%20features%20such%20as%20the%20number%20of%20boards%20you%20can%20create%20behind%20a%20paywall./singular: db6e22cc955c5fbe547feedeb48f8719
|
||||
The%20member's%20permissions%20have%20been%20updated./singular: 6ae91be2c5c0504bf521335094c50fa4
|
||||
The%20member's%20role%20has%20been%20updated./singular: e30c30a2beaac7d046c35f628102f83b
|
||||
The%20open%20source%20%3C0%2F%3E%20alternative%20to%20Trello/singular: 692cb2e8a8e610953c996826beed45a2
|
||||
The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
|
||||
Theme/singular: 21fe00b7a518089576fb83c08631107a
|
||||
@@ -547,8 +497,6 @@ checksums:
|
||||
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
|
||||
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
|
||||
This%20invitation%20link%20is%20invalid%20or%20has%20expired./singular: 11cc7ef8f1512e7e058e1fbbe5644001
|
||||
This%20member's%20permissions%20have%20been%20reset%20to%20their%20role%20defaults./singular: 730f33b8f1fc002c4f393ccd262b6664
|
||||
This%20will%20remove%20all%20custom%20member%20permissions%20in%20this%20workspace.%20Members%20will%20inherit%20permissions%20only%20from%20their%20roles./singular: 1c989752736fa41ecdd68ea890f16a15
|
||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
|
||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
|
||||
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
|
||||
@@ -566,7 +514,6 @@ checksums:
|
||||
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
||||
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
||||
Unable%20to%20add%20label/singular: b09fda6420ea1dc10dbea0b1dc373f29
|
||||
Unable%20to%20clear%20overrides/singular: dfeac280858332082ad34d2623a59863
|
||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||
@@ -579,7 +526,6 @@ checksums:
|
||||
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
||||
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
|
||||
Unable%20to%20reorder%20checklist%20item/singular: dcc238c72b85daf74ebd46adcd914473
|
||||
Unable%20to%20reset%20permissions/singular: fb09d88ff4eb32afb852d733bafd515b
|
||||
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
|
||||
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
||||
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
||||
@@ -591,8 +537,6 @@ checksums:
|
||||
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
||||
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
||||
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
|
||||
Unable%20to%20update%20permissions/singular: 134f20c5f2463167509562b284ef1c61
|
||||
Unable%20to%20update%20role/singular: 4f2240aeff6f7275feb33ca3f608e48c
|
||||
unassigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20from%20the%20card/singular: b683cc07092348c1e75dba40b1262b85
|
||||
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
|
||||
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
|
||||
@@ -651,7 +595,6 @@ checksums:
|
||||
Workspace%20name%20is%20required/singular: b8c5162dd08c4d941bc57f9d0cbee451
|
||||
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
|
||||
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
|
||||
Workspace%20permissions/singular: 72c0202f30e543eb81bf930d85647096
|
||||
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
|
||||
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
|
||||
workspace-url/singular: 2d034732ec536f3a2667f956fa50d394
|
||||
@@ -662,12 +605,10 @@ checksums:
|
||||
You%20can%20get%20a%20custom%20workspace%20URL%2C%20like%20%3C0%3Ekan.bn%2Fkan%3C%2F0%3E%2C%20by%20going%20into%20your%20%3C1%3Eworkspace%20settings%3C%2F1%3E%20and%20purchasing%20a%20pro%20workspace%20subscription.%20All%20subscriptions%20help%20fund%20the%20development%20of%20the%20project!/singular: 41dd145ef56f539e12dc064a9807c660
|
||||
You%20can%20invite%20team%20members%20by%20clicking%20the%20%22Invite%22%20button%20in%20the%20top%20right%20corner%20of%20the%20%3C0%3Emembers%20page%3C%2F0%3E%20and%20entering%20their%20email%20address.%20They%20will%20receive%20an%20email%20with%20a%20link%20to%20join%20the%20workspace./singular: 47e125eb9b4c11cab5a2f4b3ab08e883
|
||||
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
|
||||
You%20don't%20have%20permission/singular: 11d928b1993d95d54a95f85f8ae5016d
|
||||
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
|
||||
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
||||
You%20have%20been%20unsubscribed!/singular: e0b9985faa4e7f25b71acc77750923a6
|
||||
You%20have%20unlimited%20seats%20with%20your%20Pro%20Plan.%20There%20is%20no%20additional%20charge%20for%20new%20members!/singular: e3dc59a5ba7211cd3d8516b3a79d85ca
|
||||
You%20need%20to%20be%20an%20admin%20to%20manage%20workspace%20permissions./singular: e2e816f2b7a13b1056d79e7f25088664
|
||||
You've%20been%20invited%20to%20join%20a%20workspace%20on%20kan.bn./singular: 257b840726f972f384243a72767f880f
|
||||
You've%20been%20invited%20to%20join%20a%20workspace./singular: 24fc6cdc8740f37a83df85f582f03293
|
||||
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
|
||||
const config: LinguiConfig = {
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","ptbr"],
|
||||
sourceLocale: "en",
|
||||
catalogs: [
|
||||
{
|
||||
|
||||
@@ -50,11 +50,6 @@ const config = {
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.discordapp.com',
|
||||
pathname: '/avatars/**',
|
||||
},
|
||||
];
|
||||
|
||||
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
||||
@@ -95,12 +90,6 @@ const config = {
|
||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||
},
|
||||
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: env("NEXT_API_BODY_SIZE_LIMIT") || '1mb',
|
||||
},
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"start": "pnpm with-env next start",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --",
|
||||
"lingui:extract": "lingui extract",
|
||||
@@ -47,7 +45,6 @@
|
||||
"@trpc/react-query": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.26.2",
|
||||
"geist": "^1.3.1",
|
||||
"jose": "^6.1.2",
|
||||
"next": "15.5.9",
|
||||
@@ -89,8 +86,7 @@
|
||||
"jiti": "^1.21.6",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^3.0.0"
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ interface CheckboxDropdownProps {
|
||||
handleEdit?: (key: string) => void;
|
||||
handleCreate?: () => void;
|
||||
asChild?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function CheckboxDropdown({
|
||||
@@ -45,7 +44,6 @@ export default function CheckboxDropdown({
|
||||
handleEdit,
|
||||
handleCreate,
|
||||
asChild = true,
|
||||
disabled = false,
|
||||
}: CheckboxDropdownProps) {
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||
|
||||
@@ -60,13 +58,13 @@ export default function CheckboxDropdown({
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => (
|
||||
<Menu.Item key={item.key}>
|
||||
<div
|
||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={item.key}
|
||||
name={item.key}
|
||||
@@ -134,8 +132,7 @@ export default function CheckboxDropdown({
|
||||
<>
|
||||
<Menu.Button
|
||||
as={asChild ? "div" : undefined}
|
||||
disabled={disabled}
|
||||
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
|
||||
className="h-full w-full cursor-pointer focus-visible:outline-none"
|
||||
>
|
||||
{children}
|
||||
</Menu.Button>
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function Dashboard({
|
||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||
>
|
||||
<SideNavigation
|
||||
user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
|
||||
user={{ email: session?.user.email, image: session?.user.image }}
|
||||
isLoading={sessionLoading}
|
||||
onCloseSideNav={closeSideNav}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function Dropdown({
|
||||
children,
|
||||
disabled,
|
||||
}: {
|
||||
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
|
||||
items: { label: string; action: () => void; icon?: React.ReactNode }[];
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
@@ -30,14 +30,13 @@ export default function Dropdown({
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||
<div className="flex flex-col">
|
||||
{items.map((item) => (
|
||||
<Menu.Item key={item.label} disabled={item.disabled}>
|
||||
<Menu.Item key={item.label}>
|
||||
<button
|
||||
onClick={item.action}
|
||||
disabled={item.disabled ?? !item.action}
|
||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
|
||||
@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end m-3"
|
||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
|
||||
>
|
||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||
<Transition
|
||||
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div className="pointer-events-auto w-full max-w-[350px] overflow-hidden rounded-xl border border-light-400 bg-light-50 shadow-lg ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-100 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||
<div className="p-4 relative">
|
||||
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="flex-shrink-0">
|
||||
{popupIcon === "success" && (
|
||||
<HiOutlineCheckCircle
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 text-green-400"
|
||||
className="h-6 w-6 text-green-400"
|
||||
/>
|
||||
)}
|
||||
{popupIcon === "error" && (
|
||||
<HiOutlineExclamationCircle
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 text-red-400"
|
||||
className="h-6 w-6 text-red-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{popupHeader}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-neutral-500 dark:text-dark-900">
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{popupMessage}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
|
||||
<div className="ml-4 flex flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
className="inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-200"
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<HiXMark
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 text-dark-900"
|
||||
className="h-5 w-5 text-dark-900"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -14,11 +14,8 @@ import {
|
||||
HiOutlineBanknotes,
|
||||
HiOutlineCodeBracketSquare,
|
||||
HiOutlineRectangleGroup,
|
||||
HiOutlineShieldCheck,
|
||||
HiOutlineUser,
|
||||
} from "react-icons/hi2";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -27,12 +24,8 @@ interface SettingsLayoutProps {
|
||||
|
||||
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
const router = useRouter();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canViewWorkspace, canEditWorkspace } = usePermissions();
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
|
||||
const isAdmin = workspace.role === "admin";
|
||||
|
||||
const settingsTabs = [
|
||||
{
|
||||
key: "account",
|
||||
@@ -44,19 +37,13 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
key: "workspace",
|
||||
icon: <HiOutlineRectangleGroup />,
|
||||
label: t`Workspace`,
|
||||
condition: canViewWorkspace,
|
||||
},
|
||||
{
|
||||
key: "permissions",
|
||||
icon: <HiOutlineShieldCheck />,
|
||||
label: t`Permissions`,
|
||||
condition: isAdmin,
|
||||
condition: true,
|
||||
},
|
||||
{
|
||||
key: "billing",
|
||||
label: t`Billing`,
|
||||
icon: <HiOutlineBanknotes />,
|
||||
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
|
||||
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
|
||||
},
|
||||
{
|
||||
key: "api",
|
||||
@@ -68,7 +55,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
key: "integrations",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
label: t`Integrations`,
|
||||
condition: canEditWorkspace,
|
||||
condition: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -110,7 +97,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
>
|
||||
<div className="relative mb-4">
|
||||
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
||||
{availableTabs[selectedTabIndex]?.label ?? "Select a tab"}
|
||||
{availableTabs[selectedTabIndex]?.label || "Select a tab"}
|
||||
<HiChevronDown
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
||||
|
||||
@@ -39,7 +39,6 @@ interface SideNavigationProps {
|
||||
}
|
||||
|
||||
interface UserType {
|
||||
displayName?: string | null | undefined;
|
||||
email?: string | null | undefined;
|
||||
image?: string | null | undefined;
|
||||
}
|
||||
@@ -207,8 +206,7 @@ export default function SideNavigation({
|
||||
|
||||
<div className="space-y-2">
|
||||
<UserMenu
|
||||
displayName={user.displayName ?? undefined}
|
||||
email={user.email ?? "Email not provided?"}
|
||||
email={user.email ?? ""}
|
||||
imageUrl={user.image ?? undefined}
|
||||
isLoading={isLoading}
|
||||
isCollapsed={isCollapsed}
|
||||
|
||||
@@ -6,20 +6,16 @@ const Toggle = ({
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
showLabel = true,
|
||||
}: {
|
||||
isChecked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
showLabel?: boolean;
|
||||
}) => (
|
||||
<div className="mr-4 flex items-center justify-end">
|
||||
{showLabel && (
|
||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -35,7 +35,6 @@ export function Tooltip({
|
||||
delay,
|
||||
interactive: false,
|
||||
theme: "tooltip",
|
||||
touch: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
interface UserMenuProps {
|
||||
imageUrl: string | undefined;
|
||||
displayName: string | undefined;
|
||||
email: string;
|
||||
isLoading: boolean;
|
||||
isCollapsed?: boolean;
|
||||
@@ -27,7 +26,6 @@ interface UserMenuProps {
|
||||
export default function UserMenu({
|
||||
imageUrl,
|
||||
email,
|
||||
displayName,
|
||||
isLoading,
|
||||
isCollapsed = false,
|
||||
onCloseSideNav,
|
||||
@@ -77,7 +75,7 @@ export default function UserMenu({
|
||||
) : (
|
||||
<Menu.Button
|
||||
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
||||
title={isCollapsed ? (displayName || email) : undefined}
|
||||
title={isCollapsed ? email : undefined}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
@@ -104,7 +102,7 @@ export default function UserMenu({
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{displayName || email}
|
||||
{email}
|
||||
</span>
|
||||
</Menu.Button>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,6 @@ interface Props {
|
||||
positionFromTop?: "sm" | "md" | "lg";
|
||||
isVisible?: boolean;
|
||||
closeOnClickOutside?: boolean;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
const Modal: React.FC<Props> = ({
|
||||
@@ -18,7 +17,6 @@ const Modal: React.FC<Props> = ({
|
||||
positionFromTop = "md",
|
||||
isVisible,
|
||||
closeOnClickOutside,
|
||||
centered = false,
|
||||
}) => {
|
||||
const {
|
||||
isOpen,
|
||||
@@ -62,7 +60,7 @@ const Modal: React.FC<Props> = ({
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||
<div className={`flex min-h-full justify-center p-4 text-center sm:p-0 ${centered ? "items-center" : "items-start sm:items-start"}`}>
|
||||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -73,7 +71,7 @@ const Modal: React.FC<Props> = ({
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={`relative ${centered ? "" : positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
||||
className={`relative ${positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
||||
>
|
||||
{children}
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -78,7 +78,6 @@ export const env = createEnv({
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
S3_FORCE_PATH_STYLE: z.string().optional(),
|
||||
EMAIL_FROM: z.string().optional(),
|
||||
REDIS_URL: z.string().url().optional().or(z.literal("")),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -96,13 +95,6 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: z
|
||||
.string()
|
||||
.transform((s) => (s === "" ? undefined : s))
|
||||
.refine(
|
||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||
)
|
||||
.optional(),
|
||||
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||
.string()
|
||||
@@ -141,8 +133,6 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
|
||||
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS:
|
||||
process.env.NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS,
|
||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface UseDragToScrollOptions {
|
||||
/**
|
||||
* Whether drag-to-scroll is enabled
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* The direction to scroll
|
||||
*/
|
||||
direction?: "horizontal" | "vertical" | "both";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to enable drag-to-scroll functionality on a scrollable element
|
||||
* @param options Configuration options
|
||||
* @returns Ref to attach to the scrollable element and mouse event handlers
|
||||
*/
|
||||
export function useDragToScroll({
|
||||
enabled = true,
|
||||
direction = "horizontal",
|
||||
}: UseDragToScrollOptions = {}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const startPosRef = useRef({ x: 0, y: 0 });
|
||||
const scrollStartRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const deltaX = e.clientX - startPosRef.current.x;
|
||||
const deltaY = e.clientY - startPosRef.current.y;
|
||||
|
||||
if (direction === "horizontal" || direction === "both") {
|
||||
scrollRef.current.scrollLeft = scrollStartRef.current.x - deltaX;
|
||||
}
|
||||
if (direction === "vertical" || direction === "both") {
|
||||
scrollRef.current.scrollTop = scrollStartRef.current.y - deltaY;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "grabbing";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
}, [enabled, isDragging, direction]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
const container = scrollRef.current;
|
||||
|
||||
// Check if the click is on an interactive or draggable element
|
||||
// We need to be careful not to interfere with react-beautiful-dnd dragging
|
||||
const isInteractiveElement =
|
||||
target.closest("a") ||
|
||||
target.closest("button") ||
|
||||
target.closest("input") ||
|
||||
target.closest("textarea") ||
|
||||
target.closest("[role='button']") ||
|
||||
target.closest("[draggable='true']") ||
|
||||
target.closest(".react-beautiful-dnd-drag-handle") ||
|
||||
target.closest("[data-rbd-drag-handle-draggable-id]") ||
|
||||
target.closest("[data-rbd-draggable-id]");
|
||||
|
||||
// Don't start dragging if clicking on interactive elements
|
||||
if (isInteractiveElement) return;
|
||||
|
||||
// Enable drag-to-scroll for any non-interactive element within the container
|
||||
if (container.contains(target)) {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
startPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
scrollStartRef.current = {
|
||||
x: container.scrollLeft,
|
||||
y: container.scrollTop,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ref: scrollRef,
|
||||
onMouseDown: handleMouseDown,
|
||||
isDragging,
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function useLocalisation() {
|
||||
nl,
|
||||
ru,
|
||||
pl,
|
||||
"pt-BR": ptBR,
|
||||
ptbr: ptBR,
|
||||
};
|
||||
|
||||
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import type { Permission } from "@kan/shared";
|
||||
import { useContext } from "react";
|
||||
|
||||
import { WorkspaceContext } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface UsePermissionsResult {
|
||||
permissions: Permission[];
|
||||
role: string | null;
|
||||
isLoading: boolean;
|
||||
hasPermission: (permission: Permission) => boolean;
|
||||
canViewCard: boolean;
|
||||
canCreateCard: boolean;
|
||||
canEditCard: boolean;
|
||||
canDeleteCard: boolean;
|
||||
canCreateList: boolean;
|
||||
canEditList: boolean;
|
||||
canDeleteList: boolean;
|
||||
canCreateBoard: boolean;
|
||||
canEditBoard: boolean;
|
||||
canDeleteBoard: boolean;
|
||||
canViewComment: boolean;
|
||||
canCreateComment: boolean;
|
||||
canEditComment: boolean;
|
||||
canDeleteComment: boolean;
|
||||
canInviteMember: boolean;
|
||||
canEditMember: boolean;
|
||||
canRemoveMember: boolean;
|
||||
canViewWorkspace: boolean;
|
||||
canEditWorkspace: boolean;
|
||||
}
|
||||
|
||||
export function usePermissions(): UsePermissionsResult {
|
||||
// Check if WorkspaceProvider is available (for public board views, it may not be)
|
||||
const workspaceContext = useContext(WorkspaceContext);
|
||||
|
||||
// If WorkspaceProvider is not available, return safe defaults
|
||||
if (!workspaceContext) {
|
||||
const emptyPermissions: UsePermissionsResult = {
|
||||
permissions: [],
|
||||
role: null,
|
||||
isLoading: false,
|
||||
hasPermission: () => false,
|
||||
canViewCard: false,
|
||||
canCreateCard: false,
|
||||
canEditCard: false,
|
||||
canDeleteCard: false,
|
||||
canCreateList: false,
|
||||
canEditList: false,
|
||||
canDeleteList: false,
|
||||
canCreateBoard: false,
|
||||
canEditBoard: false,
|
||||
canDeleteBoard: false,
|
||||
canViewComment: false,
|
||||
canCreateComment: false,
|
||||
canEditComment: false,
|
||||
canDeleteComment: false,
|
||||
canInviteMember: false,
|
||||
canEditMember: false,
|
||||
canRemoveMember: false,
|
||||
canViewWorkspace: false,
|
||||
canEditWorkspace: false,
|
||||
};
|
||||
return emptyPermissions;
|
||||
}
|
||||
|
||||
const { workspace } = workspaceContext;
|
||||
|
||||
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{
|
||||
enabled: !!workspace.publicId,
|
||||
},
|
||||
);
|
||||
|
||||
const permissions = (data?.permissions ?? []) as Permission[];
|
||||
const role = data?.role ?? null;
|
||||
|
||||
const hasPermission = (permission: Permission): boolean => {
|
||||
return permissions.includes(permission);
|
||||
};
|
||||
|
||||
return {
|
||||
permissions,
|
||||
role,
|
||||
isLoading,
|
||||
hasPermission,
|
||||
canViewCard: hasPermission("card:view"),
|
||||
canCreateCard: hasPermission("card:create"),
|
||||
canEditCard: hasPermission("card:edit"),
|
||||
canDeleteCard: hasPermission("card:delete"),
|
||||
canCreateList: hasPermission("list:create"),
|
||||
canEditList: hasPermission("list:edit"),
|
||||
canDeleteList: hasPermission("list:delete"),
|
||||
canCreateBoard: hasPermission("board:create"),
|
||||
canEditBoard: hasPermission("board:edit"),
|
||||
canDeleteBoard: hasPermission("board:delete"),
|
||||
canViewComment: hasPermission("comment:view"),
|
||||
canCreateComment: hasPermission("comment:create"),
|
||||
canEditComment: hasPermission("comment:edit"),
|
||||
canDeleteComment: hasPermission("comment:delete"),
|
||||
canInviteMember: hasPermission("member:invite"),
|
||||
canEditMember: hasPermission("member:edit"),
|
||||
canRemoveMember: hasPermission("member:remove"),
|
||||
canViewWorkspace: hasPermission("workspace:view"),
|
||||
canEditWorkspace: hasPermission("workspace:edit"),
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ export const locales = [
|
||||
"nl",
|
||||
"ru",
|
||||
"pl",
|
||||
"pt-BR"
|
||||
"ptbr"
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
|
||||
nl: "Nederlands",
|
||||
ru: "Русский",
|
||||
pl: "Polski",
|
||||
"pt-BR": "Português",
|
||||
ptbr: "Português",
|
||||
};
|
||||
|
||||
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 one or more lines are too long
File diff suppressed because it is too large
Load Diff
1
apps/web/src/locales/ptbr/messages.ts
Normal file
1
apps/web/src/locales/ptbr/messages.ts
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -2,17 +2,9 @@ import { toNodeHandler } from "better-auth/node";
|
||||
|
||||
import { initAuth } from "@kan/auth/server";
|
||||
import { createDrizzleClient } from "@kan/db/client";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export const config = { api: { bodyParser: false } };
|
||||
|
||||
export const auth = initAuth(createDrizzleClient());
|
||||
|
||||
const authHandler = toNodeHandler(auth.handler);
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req, res) => {
|
||||
return await authHandler(req, res);
|
||||
},
|
||||
);
|
||||
export default toNodeHandler(auth.handler);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
@@ -45,5 +44,4 @@ export default withRateLimit(
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res.status(500).json({ message: "Failed to download attachment" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
@@ -21,5 +20,4 @@ export default withRateLimit(
|
||||
console.error("Error fetching OSS friends:", error);
|
||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ import { env } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
@@ -31,5 +31,4 @@ export default withRateLimit(
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating portal session" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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 { createStripeClient } from "@kan/stripe";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const workspaceSlugSchema = z
|
||||
.string()
|
||||
@@ -22,9 +21,10 @@ interface CheckoutSessionRequest {
|
||||
stripeCustomerId: string;
|
||||
}
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
@@ -115,5 +115,4 @@ export default withRateLimit(
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating checkout session" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { integrations } from "@kan/db/schema";
|
||||
import { addYears } from "date-fns";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
@@ -48,5 +48,4 @@ export default withRateLimit(
|
||||
console.error("Trello authentication error:", err);
|
||||
return res.status(400).json({ message: "Trello authentication failed" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,12 @@ import { createNextApiHandler } from "@trpc/server/adapters/next";
|
||||
|
||||
import { appRouter } from "@kan/api/root";
|
||||
import { createTRPCContext } from "@kan/api/trpc";
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const nextApiHandler = createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext: createTRPCContext,
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
process.env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
@@ -19,16 +17,11 @@ const nextApiHandler = createNextApiHandler({
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(200);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
const result = await nextApiHandler(req, res);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
return nextApiHandler(req, res);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { jwtVerify } from "jose";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const requestSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
@@ -20,9 +19,10 @@ type ResponseData =
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>,
|
||||
) {
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
@@ -101,5 +101,4 @@ export default withRateLimit(
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import { env as nextRuntimeEnv } from "next-runtime-env";
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
@@ -71,5 +71,4 @@ export default withRateLimit(
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,26 +6,25 @@ import { appRouter } from "@kan/api";
|
||||
import { createRESTContext } from "@kan/api/trpc";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
await cors(req, res);
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
await cors(req, res);
|
||||
|
||||
const openApiHandler = createOpenApiNextHandler({
|
||||
router: appRouter,
|
||||
createContext: createRESTContext,
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const openApiHandler = createOpenApiNextHandler({
|
||||
router: appRouter,
|
||||
createContext: createRESTContext,
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return await openApiHandler(req, res);
|
||||
},
|
||||
);
|
||||
return await openApiHandler(req, res);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { openApiDocument } from "@kan/api/openapi";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
(req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.status(200).send(openApiDocument);
|
||||
},
|
||||
);
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.status(200).send(openApiDocument);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
@@ -2,13 +2,11 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import ApiSettings from "~/views/settings/ApiSettings";
|
||||
import Popup from "~/components/Popup";
|
||||
|
||||
const ApiSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="api">
|
||||
<ApiSettings />
|
||||
<Popup />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,13 +2,11 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import BillingSettings from "~/views/settings/BillingSettings";
|
||||
import Popup from "~/components/Popup";
|
||||
|
||||
const BillingSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="billing">
|
||||
<BillingSettings />
|
||||
<Popup />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,13 +2,11 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
||||
import Popup from "~/components/Popup";
|
||||
|
||||
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="integrations">
|
||||
<IntegrationsSettings />
|
||||
<Popup />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import Popup from "~/components/Popup";
|
||||
import PermissionsSettings from "~/views/settings/PermissionsSettings";
|
||||
|
||||
const PermissionsSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<>
|
||||
<SettingsLayout currentTab="permissions">
|
||||
<PermissionsSettings />
|
||||
<Popup />
|
||||
</SettingsLayout>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
PermissionsSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default PermissionsSettingsPage;
|
||||
|
||||
|
||||
@@ -2,13 +2,11 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
|
||||
import Popup from "~/components/Popup";
|
||||
|
||||
const WorkspaceSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="workspace">
|
||||
<WorkspaceSettings />
|
||||
<Popup />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ const initialWorkspace: Workspace = {
|
||||
|
||||
const initialAvailableWorkspaces: Workspace[] = [];
|
||||
|
||||
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
||||
const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("next-runtime-env", () => ({
|
||||
env: vi.fn(),
|
||||
}));
|
||||
|
||||
import { env } from "next-runtime-env";
|
||||
import { getAvatarUrl } from "./helpers";
|
||||
|
||||
const mockEnv = env as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("getAvatarUrl", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty string for null input", () => {
|
||||
expect(getAvatarUrl(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty string input", () => {
|
||||
expect(getAvatarUrl("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns URL unchanged if already absolute http", () => {
|
||||
expect(getAvatarUrl("http://example.com/avatar.jpg")).toBe(
|
||||
"http://example.com/avatar.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns URL unchanged if already absolute https", () => {
|
||||
expect(getAvatarUrl("https://example.com/avatar.jpg")).toBe(
|
||||
"https://example.com/avatar.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
describe("path-style URLs (MinIO/LocalStack)", () => {
|
||||
it("constructs path-style URL when STORAGE_DOMAIN is not set", () => {
|
||||
mockEnv.mockImplementation((key: string) => {
|
||||
const vars: Record<string, string> = {
|
||||
NEXT_PUBLIC_STORAGE_URL: "http://s3.localtest.me:9000",
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan",
|
||||
};
|
||||
return vars[key];
|
||||
});
|
||||
|
||||
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||
"http://s3.localtest.me:9000/kan/user123/avatar.jpg",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("virtual-hosted URLs (Tigris/AWS S3)", () => {
|
||||
it("constructs virtual-hosted URL when USE_VIRTUAL_HOSTED_URLS is true and STORAGE_DOMAIN is set", () => {
|
||||
mockEnv.mockImplementation((key: string) => {
|
||||
const vars: Record<string, string> = {
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: "true",
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
|
||||
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
|
||||
};
|
||||
return vars[key];
|
||||
});
|
||||
|
||||
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||
"https://kan-avatars.fly.storage.tigris.dev/user123/avatar.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses path-style URL when USE_VIRTUAL_HOSTED_URLS is false even if STORAGE_DOMAIN is set", () => {
|
||||
mockEnv.mockImplementation((key: string) => {
|
||||
const vars: Record<string, string> = {
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: "false",
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
|
||||
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
|
||||
};
|
||||
return vars[key];
|
||||
});
|
||||
|
||||
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||
"https://fly.storage.tigris.dev/kan-avatars/user123/avatar.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses path-style URL when USE_VIRTUAL_HOSTED_URLS is not set even if STORAGE_DOMAIN is set", () => {
|
||||
mockEnv.mockImplementation((key: string) => {
|
||||
const vars: Record<string, string> = {
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
|
||||
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
|
||||
};
|
||||
return vars[key];
|
||||
});
|
||||
|
||||
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||
"https://fly.storage.tigris.dev/kan-avatars/user123/avatar.jpg",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,14 +52,5 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
||||
return imageOrKey;
|
||||
}
|
||||
|
||||
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
||||
const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS");
|
||||
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
|
||||
|
||||
if (useVirtualHostedUrls === "true" && storageDomain) {
|
||||
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
|
||||
}
|
||||
|
||||
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
|
||||
return `${storageUrl}/${bucket}/${imageOrKey}`;
|
||||
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,9 @@ import {
|
||||
HiLink,
|
||||
HiOutlineDocumentDuplicate,
|
||||
HiOutlineTrash,
|
||||
HiOutlineStar,
|
||||
HiStar,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -18,104 +15,42 @@ export default function BoardDropdown({
|
||||
isTemplate,
|
||||
isLoading,
|
||||
boardPublicId,
|
||||
isFavorite,
|
||||
boardName,
|
||||
workspacePublicId,
|
||||
}: {
|
||||
isTemplate: boolean;
|
||||
isLoading: boolean;
|
||||
boardPublicId: string;
|
||||
isFavorite?: boolean;
|
||||
boardName?: string;
|
||||
workspacePublicId: string;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
favorite: !isFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
const updateBoard = api.board.update.useMutation({
|
||||
onSuccess: (data, variables) => {
|
||||
void utils.board.all.invalidate();
|
||||
void utils.board.byId.invalidate();
|
||||
|
||||
// Show popup notification
|
||||
if (variables.favorite !== undefined) {
|
||||
showPopup({
|
||||
header: variables.favorite
|
||||
? t`Added to favorites`
|
||||
: t`Removed from favorites`,
|
||||
message: variables.favorite
|
||||
? t`${boardName ?? "Board"} has been added to your favorites.`
|
||||
: t`${boardName ?? "Board"} has been removed from your favorites.`,
|
||||
icon: "success",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to update board`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const items = [
|
||||
...(isTemplate && canCreateBoard
|
||||
? [
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isTemplate && canEditBoard
|
||||
? [
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: isFavorite
|
||||
? t`Remove from favorites`
|
||||
: t`Add to favorites`,
|
||||
action: handleToggleFavorite,
|
||||
icon: isFavorite ? (
|
||||
<HiStar className="h-[16px] w-[16px] text-dark-900" />
|
||||
) : (
|
||||
<HiOutlineStar className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
...(canDeleteBoard
|
||||
? [
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown disabled={isLoading} items={items}>
|
||||
<Dropdown
|
||||
disabled={isLoading}
|
||||
items={[
|
||||
...(isTemplate
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]),
|
||||
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -9,11 +9,7 @@ import {
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
@@ -27,7 +23,6 @@ interface ListProps {
|
||||
interface List {
|
||||
publicId: string;
|
||||
name: string;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
@@ -44,14 +39,8 @@ export default function List({
|
||||
setSelectedPublicListId,
|
||||
}: ListProps) {
|
||||
const { openModal } = useModal();
|
||||
const { canCreateCard, canEditList, canDeleteList } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const isCreator = list.createdBy && session?.user.id === list.createdBy;
|
||||
const canEdit = canEditList || isCreator;
|
||||
const canDrag = canEditList || isCreator;
|
||||
|
||||
const openNewCardForm = (publicListId: PublicListId) => {
|
||||
if (!canCreateCard) return;
|
||||
openModal("NEW_CARD");
|
||||
setSelectedPublicListId(publicListId);
|
||||
};
|
||||
@@ -70,7 +59,6 @@ export default function List({
|
||||
});
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
if (!canEdit) return;
|
||||
updateList.mutate({
|
||||
listPublicId: values.listPublicId,
|
||||
name: values.name,
|
||||
@@ -83,12 +71,7 @@ export default function List({
|
||||
};
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={list.publicId}
|
||||
draggableId={list.publicId}
|
||||
index={index}
|
||||
isDragDisabled={!canDrag}
|
||||
>
|
||||
<Draggable key={list.publicId} draggableId={list.publicId} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
key={list.publicId}
|
||||
@@ -107,65 +90,41 @@ export default function List({
|
||||
type="text"
|
||||
{...register("name")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
readOnly={!canEdit}
|
||||
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
||||
/>
|
||||
</form>
|
||||
<div className="flex items-center">
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateCard ? t`You don't have permission` : undefined
|
||||
}
|
||||
<button
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
|
||||
onClick={() => openNewCardForm(list.publicId)}
|
||||
>
|
||||
<button
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 disabled:opacity-60 disabled:cursor-not-allowed dark:hover:bg-dark-200"
|
||||
onClick={() => openNewCardForm(list.publicId)}
|
||||
disabled={!canCreateCard}
|
||||
<HiOutlinePlusSmall
|
||||
className="h-5 w-5 text-dark-900"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div className="relative mr-1 inline-block">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Add a card`,
|
||||
action: () => openNewCardForm(list.publicId),
|
||||
icon: (
|
||||
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Delete list`,
|
||||
action: handleOpenDeleteListConfirmation,
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiOutlinePlusSmall
|
||||
className="h-5 w-5 text-dark-900"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{(() => {
|
||||
const dropdownItems = [
|
||||
...(canCreateCard
|
||||
? [
|
||||
{
|
||||
label: t`Add a card`,
|
||||
action: () => openNewCardForm(list.publicId),
|
||||
icon: (
|
||||
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canDeleteList || isCreator
|
||||
? [
|
||||
{
|
||||
label: t`Delete list`,
|
||||
action: handleOpenDeleteListConfirmation,
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
if (dropdownItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mr-1 inline-block">
|
||||
<Dropdown items={dropdownItems}>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { HiLink } from "react-icons/hi";
|
||||
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
|
||||
const UpdateBoardSlugButton = ({
|
||||
handleOnClick,
|
||||
workspaceSlug,
|
||||
boardSlug,
|
||||
isLoading,
|
||||
canEdit,
|
||||
}: {
|
||||
handleOnClick: () => void;
|
||||
workspaceSlug: string;
|
||||
boardSlug: string;
|
||||
isLoading: boolean;
|
||||
canEdit: boolean;
|
||||
}) => {
|
||||
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
|
||||
|
||||
@@ -27,14 +22,10 @@ const UpdateBoardSlugButton = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={!canEdit && !isLoading ? t`You don't have permission` : undefined}
|
||||
<button
|
||||
onClick={handleOnClick}
|
||||
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
||||
>
|
||||
<button
|
||||
onClick={canEdit ? handleOnClick : undefined}
|
||||
disabled={!canEdit || isLoading}
|
||||
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span>
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
@@ -50,20 +41,13 @@ const UpdateBoardSlugButton = ({
|
||||
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!canEdit) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
||||
>
|
||||
<HiLink className="h-[13px] w-[13px]" />
|
||||
</Link>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default UpdateBoardSlugButton;
|
||||
|
||||
@@ -4,8 +4,6 @@ import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
@@ -31,7 +29,6 @@ const VisibilityButton = ({
|
||||
isAdmin: boolean;
|
||||
}) => {
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditBoard } = usePermissions();
|
||||
const utils = api.useUtils();
|
||||
const [stateVisibility, setStateVisibility] = useState<"public" | "private">(
|
||||
visibility,
|
||||
@@ -63,47 +60,38 @@ const VisibilityButton = ({
|
||||
},
|
||||
});
|
||||
|
||||
const canEdit = canEditBoard || isAdmin;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Tooltip
|
||||
content={
|
||||
!canEdit && !isLoading ? t`You don't have permission` : undefined
|
||||
}
|
||||
<CheckboxDropdown
|
||||
items={[
|
||||
{
|
||||
key: "public",
|
||||
value: t`Public`,
|
||||
selected: isPublic,
|
||||
},
|
||||
{
|
||||
key: "private",
|
||||
value: t`Private`,
|
||||
selected: !isPublic,
|
||||
},
|
||||
]}
|
||||
handleSelect={(_g, i) => {
|
||||
setStateVisibility(isPublic ? "private" : "public");
|
||||
updateBoardVisibility.mutate({
|
||||
visibility: i.key as "public" | "private",
|
||||
boardPublicId,
|
||||
});
|
||||
}}
|
||||
menuSpacing="md"
|
||||
>
|
||||
<CheckboxDropdown
|
||||
items={[
|
||||
{
|
||||
key: "public",
|
||||
value: t`Public`,
|
||||
selected: isPublic,
|
||||
},
|
||||
{
|
||||
key: "private",
|
||||
value: t`Private`,
|
||||
selected: !isPublic,
|
||||
},
|
||||
]}
|
||||
handleSelect={(_g, i) => {
|
||||
if (!canEdit) return;
|
||||
setStateVisibility(isPublic ? "private" : "public");
|
||||
updateBoardVisibility.mutate({
|
||||
visibility: i.key as "public" | "private",
|
||||
boardPublicId,
|
||||
});
|
||||
}}
|
||||
menuSpacing="md"
|
||||
<Button
|
||||
variant="secondary"
|
||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||
disabled={isLoading || !isAdmin}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||
disabled={isLoading || !canEdit}
|
||||
>
|
||||
{t`Visibility`}
|
||||
</Button>
|
||||
</CheckboxDropdown>
|
||||
</Tooltip>
|
||||
{t`Visibility`}
|
||||
</Button>
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,8 +25,6 @@ import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -58,19 +56,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const [selectedPublicListId, setSelectedPublicListId] =
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions();
|
||||
|
||||
const { tooltipContent: createListShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
type: "PRESS",
|
||||
stroke: { key: "C" },
|
||||
action: () => boardId && canCreateList && openNewListForm(boardId),
|
||||
action: () => boardId && openNewListForm(boardId),
|
||||
description: t`Create new list`,
|
||||
group: "ACTIONS",
|
||||
});
|
||||
@@ -263,14 +254,14 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "LIST" && canEditList) {
|
||||
if (type === "LIST") {
|
||||
updateListMutation.mutate({
|
||||
listPublicId: draggableId,
|
||||
index: destination.index,
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "CARD" && canEditCard) {
|
||||
if (type === "CARD") {
|
||||
updateCardMutation.mutate({
|
||||
cardPublicId: draggableId,
|
||||
|
||||
@@ -415,12 +406,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
id="name"
|
||||
type="text"
|
||||
{...register("name")}
|
||||
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
|
||||
readOnly={!canEditBoard}
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem] disabled:cursor-not-allowed"
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
/>
|
||||
</form>
|
||||
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||
@@ -443,7 +432,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isLoading={isLoading}
|
||||
workspaceSlug={workspace.slug ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
canEdit={canEditBoard}
|
||||
/>
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
@@ -466,13 +454,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateList
|
||||
? t`You don't have permission`
|
||||
: createListShortcutTooltipContent
|
||||
}
|
||||
>
|
||||
<Tooltip content={createListShortcutTooltipContent}>
|
||||
<Button
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall
|
||||
@@ -481,9 +463,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
if (boardId && canCreateList) openNewListForm(boardId);
|
||||
if (boardId) openNewListForm(boardId);
|
||||
}}
|
||||
disabled={!boardData || !canCreateList}
|
||||
disabled={!boardData}
|
||||
>
|
||||
{t`New list`}
|
||||
</Button>
|
||||
@@ -493,17 +475,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isLoading={!boardData}
|
||||
boardPublicId={boardId ?? ""}
|
||||
workspacePublicId={workspace.publicId}
|
||||
isFavorite={boardData?.favorite}
|
||||
boardName={boardData?.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onMouseDown={onMouseDown}
|
||||
className={`scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300`}
|
||||
>
|
||||
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
||||
{isLoading ? (
|
||||
<div className="ml-[2rem] flex">
|
||||
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
@@ -520,25 +496,16 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{t`No lists`}
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
{canCreateList
|
||||
? t`Get started by creating a new list`
|
||||
: t`No lists have been created yet`}
|
||||
{t`Get started by creating a new list`}
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateList ? t`You don't have permission` : undefined
|
||||
}
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (boardId && canCreateList) openNewListForm(boardId);
|
||||
}}
|
||||
disabled={!canCreateList}
|
||||
>
|
||||
{t`Create new list`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{t`Create new list`}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
@@ -578,7 +545,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
key={card.publicId}
|
||||
draggableId={card.publicId}
|
||||
index={index}
|
||||
isDragDisabled={!canEditCard}
|
||||
>
|
||||
{(provided) => (
|
||||
<Link
|
||||
@@ -596,12 +562,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
? `/templates/${boardId}/cards/${card.publicId}`
|
||||
: `/cards/${card.publicId}`
|
||||
}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiOutlineRectangleStack, HiOutlineStar, HiStar } from "react-icons/hi2";
|
||||
import { motion } from "framer-motion";
|
||||
import { HiOutlineRectangleStack } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -13,14 +11,6 @@ import { api } from "~/utils/api";
|
||||
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const { canCreateBoard } = usePermissions();
|
||||
|
||||
const utils = api.useUtils();
|
||||
const updateBoard = api.board.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.board.all.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isLoading } = api.board.all.useQuery(
|
||||
{
|
||||
@@ -30,20 +20,6 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{ enabled: workspace.publicId ? true : false },
|
||||
);
|
||||
|
||||
const handleToggleFavorite = (
|
||||
e: React.MouseEvent,
|
||||
boardPublicId: string,
|
||||
currentFavorite: boolean | undefined
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
favorite: !currentFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
||||
@@ -65,69 +41,27 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateBoard ? t`You don't have permission` : undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (canCreateBoard) openModal("NEW_BOARD");
|
||||
}}
|
||||
disabled={!canCreateBoard}
|
||||
>
|
||||
{t`Create new ${isTemplate ? "template" : "board"}`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button onClick={() => openModal("NEW_BOARD")}>
|
||||
{t`Create new ${isTemplate ? "template" : "board"}`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3"
|
||||
layout
|
||||
>
|
||||
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
||||
{data?.map((board) => (
|
||||
<motion.div
|
||||
<Link
|
||||
key={board.publicId}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
layout: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
mass: 1
|
||||
},
|
||||
opacity: { duration: 0.2 },
|
||||
scale: { duration: 0.2 }
|
||||
}}
|
||||
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
||||
>
|
||||
<Link
|
||||
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
||||
>
|
||||
<div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||
<PatternedBackground />
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
||||
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{board.favorite ? (
|
||||
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
|
||||
)}
|
||||
</button>
|
||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||
<PatternedBackground />
|
||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
@@ -18,13 +17,12 @@ import { NewBoardForm } from "./components/NewBoardForm";
|
||||
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { openModal, modalContentType, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canCreateBoard } = usePermissions();
|
||||
|
||||
const { tooltipContent: createModalShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
type: "PRESS",
|
||||
stroke: { key: "C" },
|
||||
action: () => canCreateBoard && openModal("NEW_BOARD"),
|
||||
action: () => openModal("NEW_BOARD"),
|
||||
description: t`Create new ${isTemplate ? "template" : "board"}`,
|
||||
group: "ACTIONS",
|
||||
});
|
||||
@@ -41,40 +39,22 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
{!isTemplate && (
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateBoard ? t`You don't have permission` : undefined
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => openModal("IMPORT_BOARDS")}
|
||||
iconLeft={
|
||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (canCreateBoard) openModal("IMPORT_BOARDS");
|
||||
}}
|
||||
disabled={!canCreateBoard}
|
||||
iconLeft={
|
||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
{t`Import`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{t`Import`}
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateBoard
|
||||
? t`You don't have permission`
|
||||
: createModalShortcutTooltipContent
|
||||
}
|
||||
>
|
||||
<Tooltip content={createModalShortcutTooltipContent}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (canCreateBoard) openModal("NEW_BOARD");
|
||||
}}
|
||||
disabled={!canCreateBoard}
|
||||
onClick={() => openModal("NEW_BOARD")}
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import { authClient } from "@kan/auth/client";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||
import { api } from "~/utils/api";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import Comment from "./Comment";
|
||||
|
||||
type ActivityType =
|
||||
@@ -472,7 +471,6 @@ const ActivityList = ({
|
||||
cardPublicId={cardPublicId}
|
||||
name={activity.user?.name ?? ""}
|
||||
email={activity.user?.email ?? ""}
|
||||
image={activity.user?.image ?? null}
|
||||
isLoading={isLoading}
|
||||
createdAt={activity.createdAt.toISOString()}
|
||||
comment={activity.comment?.comment}
|
||||
@@ -495,7 +493,6 @@ const ActivityList = ({
|
||||
size="sm"
|
||||
name={activity.user?.name ?? ""}
|
||||
email={activity.user?.email ?? ""}
|
||||
imageUrl={getAvatarUrl(activity.user?.image ?? null) || undefined}
|
||||
icon={getActivityIcon(
|
||||
activity.type,
|
||||
activity.fromList?.index,
|
||||
|
||||
@@ -8,12 +8,10 @@ import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
interface FormValues {
|
||||
comment: string;
|
||||
@@ -24,7 +22,6 @@ const Comment = ({
|
||||
cardPublicId,
|
||||
name,
|
||||
email,
|
||||
image,
|
||||
isLoading,
|
||||
createdAt,
|
||||
comment,
|
||||
@@ -37,7 +34,6 @@ const Comment = ({
|
||||
cardPublicId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image: string | null;
|
||||
isLoading: boolean;
|
||||
createdAt: string;
|
||||
comment: string | undefined;
|
||||
@@ -50,7 +46,6 @@ const Comment = ({
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const { openModal } = useModal();
|
||||
const { canEditComment, canDeleteComment } = usePermissions();
|
||||
const { handleSubmit, setValue, watch } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
comment,
|
||||
@@ -82,7 +77,7 @@ const Comment = ({
|
||||
};
|
||||
|
||||
const dropdownItems = [
|
||||
...(isAuthor && canEditComment
|
||||
...(isAuthor
|
||||
? [
|
||||
{
|
||||
label: t`Edit comment`,
|
||||
@@ -91,7 +86,7 @@ const Comment = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...((isAuthor || canDeleteComment)
|
||||
...(isAuthor || isAdmin
|
||||
? [
|
||||
{
|
||||
label: t`Delete comment`,
|
||||
@@ -113,7 +108,6 @@ const Comment = ({
|
||||
size="sm"
|
||||
name={name ?? ""}
|
||||
email={email ?? ""}
|
||||
imageUrl={getAvatarUrl(image) || undefined}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,53 +5,29 @@ import {
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
|
||||
export default function CardDropdown({
|
||||
cardCreatedBy,
|
||||
}: {
|
||||
cardCreatedBy?: string | null;
|
||||
}) {
|
||||
export default function BoardDropdown() {
|
||||
const { openModal } = useModal();
|
||||
const { canEditCard, canDeleteCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
||||
|
||||
const items = [
|
||||
...(canEditCard
|
||||
? [
|
||||
{
|
||||
label: t`Add checklist`,
|
||||
action: () => openModal("ADD_CHECKLIST"),
|
||||
icon: (
|
||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canDeleteCard || isCreator
|
||||
? [
|
||||
{
|
||||
label: t`Delete card`,
|
||||
action: () => openModal("DELETE_CARD"),
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Add checklist`,
|
||||
action: () => openModal("ADD_CHECKLIST"),
|
||||
icon: (
|
||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Delete card`,
|
||||
action: () => openModal("DELETE_CARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -12,14 +12,12 @@ interface DueDateSelectorProps {
|
||||
cardPublicId: string;
|
||||
dueDate: Date | null | undefined;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DueDateSelector({
|
||||
cardPublicId,
|
||||
dueDate,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
}: DueDateSelectorProps) {
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
@@ -107,9 +105,9 @@ export function DueDateSelector({
|
||||
<div className="relative flex w-full items-center text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={isLoading || disabled}
|
||||
className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100"
|
||||
>
|
||||
{dueDate ? (
|
||||
<span>{format(dueDate, "MMM d, yyyy")}</span>
|
||||
@@ -120,7 +118,7 @@ export function DueDateSelector({
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && !disabled && (
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
|
||||
<div
|
||||
|
||||
@@ -17,14 +17,12 @@ interface LabelSelectorProps {
|
||||
leftIcon: React.ReactNode;
|
||||
}[];
|
||||
isLoading: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function LabelSelector({
|
||||
cardPublicId,
|
||||
labels,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
}: LabelSelectorProps) {
|
||||
const utils = api.useUtils();
|
||||
const { openModal } = useModal();
|
||||
@@ -95,10 +93,9 @@ export default function LabelSelector({
|
||||
handleSelect={(_, label) => {
|
||||
addOrRemoveLabel.mutate({ cardPublicId, labelPublicId: label.key });
|
||||
}}
|
||||
handleEdit={disabled ? undefined : (labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
|
||||
handleCreate={disabled ? undefined : () => openModal("NEW_LABEL")}
|
||||
handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
|
||||
handleCreate={() => openModal("NEW_LABEL")}
|
||||
createNewItemLabel={t`Create new label`}
|
||||
disabled={disabled}
|
||||
asChild
|
||||
>
|
||||
{selectedLabels.length ? (
|
||||
@@ -113,7 +110,7 @@ export default function LabelSelector({
|
||||
<Badge value={t`Add label`} iconLeft={<HiMiniPlus size={14} />} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<HiMiniPlus size={22} className="pr-2" />
|
||||
{t`Add label`}
|
||||
</div>
|
||||
|
||||
@@ -13,14 +13,12 @@ interface ListSelectorProps {
|
||||
selected: boolean;
|
||||
}[];
|
||||
isLoading: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ListSelector({
|
||||
cardPublicId,
|
||||
lists,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
}: ListSelectorProps) {
|
||||
const utils = api.useUtils();
|
||||
|
||||
@@ -79,10 +77,9 @@ export default function ListSelector({
|
||||
index: 0,
|
||||
});
|
||||
}}
|
||||
disabled={disabled}
|
||||
asChild
|
||||
>
|
||||
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
{selectedList?.value}
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
|
||||
@@ -19,14 +19,12 @@ interface MemberSelectorProps {
|
||||
imageUrl: string | undefined;
|
||||
}[];
|
||||
isLoading: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function MemberSelector({
|
||||
cardPublicId,
|
||||
members,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
}: MemberSelectorProps) {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
@@ -110,12 +108,11 @@ export default function MemberSelector({
|
||||
workspaceMemberPublicId: member.key,
|
||||
});
|
||||
}}
|
||||
handleCreate={disabled ? undefined : handleInviteMember}
|
||||
handleCreate={handleInviteMember}
|
||||
createNewItemLabel={t`Invite member`}
|
||||
disabled={disabled}
|
||||
asChild
|
||||
>
|
||||
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
{selectedMembers.length ? (
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{selectedMembers.map(({ value, imageUrl }) => (
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useForm } from "react-hook-form";
|
||||
import { HiOutlineArrowUp } from "react-icons/hi2";
|
||||
|
||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
@@ -16,7 +15,6 @@ interface FormValues {
|
||||
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const { canCreateComment } = usePermissions();
|
||||
const { handleSubmit, setValue, watch, reset } = useForm<FormValues>({
|
||||
values: {
|
||||
comment: "",
|
||||
@@ -48,10 +46,6 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
});
|
||||
};
|
||||
|
||||
if (!canCreateComment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
|
||||
@@ -14,9 +14,6 @@ import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
@@ -47,8 +44,6 @@ interface FormValues {
|
||||
|
||||
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const router = useRouter();
|
||||
const { canEditCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const cardId = Array.isArray(router.query.cardId)
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
@@ -57,9 +52,6 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId: cardId ?? "",
|
||||
});
|
||||
|
||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||
const canEdit = canEditCard || isCreator;
|
||||
|
||||
const board = card?.list.board;
|
||||
const labels = board?.labels;
|
||||
const workspaceMembers = board?.workspace.members;
|
||||
@@ -124,7 +116,6 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId ?? ""}
|
||||
lists={formattedLists}
|
||||
isLoading={!card}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
@@ -133,7 +124,6 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId ?? ""}
|
||||
labels={formattedLabels}
|
||||
isLoading={!card}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
@@ -143,7 +133,6 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId ?? ""}
|
||||
members={formattedMembers}
|
||||
isLoading={!card}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -153,7 +142,6 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId ?? ""}
|
||||
dueDate={card?.dueDate}
|
||||
isLoading={!card}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,8 +162,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
} = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canEditCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -188,9 +174,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId: cardId ?? "",
|
||||
});
|
||||
|
||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||
const canEdit = canEditCard || isCreator;
|
||||
|
||||
const refetchCard = async () => {
|
||||
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
|
||||
};
|
||||
@@ -315,7 +298,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown cardCreatedBy={card?.createdBy} />
|
||||
<Dropdown />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -343,10 +326,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<textarea
|
||||
id="title"
|
||||
{...register("title")}
|
||||
onBlur={canEdit ? handleSubmit(onSubmit) : undefined}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
rows={1}
|
||||
disabled={!canEdit}
|
||||
className={`block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem] ${!canEdit ? "cursor-default" : ""}`}
|
||||
className="block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
@@ -372,10 +354,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={card.description}
|
||||
onChange={canEdit ? (e) => setValue("description", e) : undefined}
|
||||
onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined}
|
||||
onChange={(e) => setValue("description", e)}
|
||||
onBlur={() => handleSubmit(onSubmit)()}
|
||||
workspaceMembers={board?.workspace.members ?? []}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
@@ -385,7 +366,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId}
|
||||
activeChecklistForm={activeChecklistForm}
|
||||
setActiveChecklistForm={setActiveChecklistForm}
|
||||
viewOnly={!canEdit}
|
||||
/>
|
||||
{!isTemplate && (
|
||||
<>
|
||||
@@ -394,15 +374,12 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<AttachmentThumbnails
|
||||
attachments={card.attachments}
|
||||
cardPublicId={cardId ?? ""}
|
||||
isReadOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div className="mt-6">
|
||||
<AttachmentUpload cardPublicId={cardId} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<AttachmentUpload cardPublicId={cardId} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import type { Permission } from "@kan/shared";
|
||||
import { permissionCategories } from "@kan/shared";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function EditMemberPermissionsModal() {
|
||||
const { workspace } = useWorkspace();
|
||||
const { modalContentType, entityId, entityLabel, closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data, isLoading } = api.permission.getMemberPermissions.useQuery(
|
||||
{
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
modalContentType === "EDIT_MEMBER_PERMISSIONS" && !!entityId,
|
||||
},
|
||||
);
|
||||
|
||||
const grantMutation = api.permission.grantPermission.useMutation({
|
||||
onSuccess: () => {
|
||||
showPopup({
|
||||
header: t`Permissions updated`,
|
||||
message: t`The member's permissions have been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to update permissions`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.permission.getMemberPermissions.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = api.permission.revokePermission.useMutation({
|
||||
onSuccess: () => {
|
||||
showPopup({
|
||||
header: t`Permissions updated`,
|
||||
message: t`The member's permissions have been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to update permissions`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.permission.getMemberPermissions.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = api.permission.resetMemberPermissions.useMutation({
|
||||
onSuccess: async () => {
|
||||
showPopup({
|
||||
header: t`Permissions reset`,
|
||||
message: t`This member's permissions have been reset to their role defaults.`,
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
await utils.permission.getMemberPermissions.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to reset permissions`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const effectivePermissions = (data?.permissions ?? []) as Permission[];
|
||||
const hasOverrides = (data?.overrides?.length ?? 0) > 0;
|
||||
const isBusy =
|
||||
grantMutation.isPending ||
|
||||
revokeMutation.isPending ||
|
||||
resetMutation.isPending;
|
||||
|
||||
const handleToggle = (permission: Permission, nextState: boolean) => {
|
||||
if (!workspace.publicId || !entityId) return;
|
||||
|
||||
if (nextState) {
|
||||
grantMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
permission,
|
||||
});
|
||||
} else {
|
||||
revokeMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const permissionLabels: Record<Permission, string> = {
|
||||
"workspace:view": t`Can view workspace`,
|
||||
"workspace:edit": t`Can edit workspace`,
|
||||
"workspace:delete": t`Can delete workspace`,
|
||||
"workspace:manage": t`Can manage workspace settings`,
|
||||
|
||||
"board:view": t`Can view boards`,
|
||||
"board:create": t`Can create boards`,
|
||||
"board:edit": t`Can edit boards`,
|
||||
"board:delete": t`Can delete boards`,
|
||||
|
||||
"list:view": t`Can view lists`,
|
||||
"list:create": t`Can create lists`,
|
||||
"list:edit": t`Can edit lists`,
|
||||
"list:delete": t`Can delete lists`,
|
||||
|
||||
"card:view": t`Can view cards`,
|
||||
"card:create": t`Can create cards`,
|
||||
"card:edit": t`Can edit cards`,
|
||||
"card:delete": t`Can delete cards`,
|
||||
|
||||
"comment:view": t`Can view comments`,
|
||||
"comment:create": t`Can add comments`,
|
||||
"comment:edit": t`Can edit comments`,
|
||||
"comment:delete": t`Can delete comments`,
|
||||
|
||||
"member:view": t`Can view members`,
|
||||
"member:invite": t`Can invite members`,
|
||||
"member:edit": t`Can edit member roles and permissions`,
|
||||
"member:remove": t`Can remove members`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-md bg-light-50 text-light-1000 dark:bg-dark-100 dark:text-dark-1000">
|
||||
<div className="px-5 pt-5">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="mb-1 text-sm font-semibold">
|
||||
{t`Edit permissions`}
|
||||
</h2>
|
||||
<p className="min-h-[16px] text-xs text-light-900 dark:text-dark-900">
|
||||
{entityLabel}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="ml-2 inline-flex h-6 w-6 items-center justify-center rounded-md text-light-900 hover:bg-light-200 focus:outline-none dark:text-dark-900 dark:hover:bg-dark-200"
|
||||
aria-label={t`Close`}
|
||||
>
|
||||
<HiXMark className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-light-900 dark:text-dark-900">
|
||||
{t`Loading permissions...`}
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-80 pb-4 space-y-3 overflow-y-auto pr-1">
|
||||
{Object.values(permissionCategories).map((category, index) => (
|
||||
<div
|
||||
key={category.label}
|
||||
className={`py-2 ${
|
||||
index > 0
|
||||
? "border-t border-light-300 dark:border-dark-300"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="my-2 text-[12px] font-semibold text-light-900 dark:text-dark-950">
|
||||
{category.label}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{category.permissions.map((permission) => {
|
||||
const label =
|
||||
permissionLabels[permission] ?? (permission as string);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={permission}
|
||||
className="flex items-center justify-between gap-3 py-0.5"
|
||||
>
|
||||
<span className="text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
<Toggle
|
||||
label={label}
|
||||
showLabel={false}
|
||||
isChecked={effectivePermissions.includes(permission)}
|
||||
disabled={isBusy}
|
||||
onChange={() =>
|
||||
handleToggle(
|
||||
permission,
|
||||
!effectivePermissions.includes(permission),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!workspace.publicId || !entityId || isBusy) return;
|
||||
resetMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
});
|
||||
}}
|
||||
disabled={isBusy || !hasOverrides}
|
||||
isLoading={resetMutation.isPending}
|
||||
>
|
||||
{t`Reset to role defaults`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import {
|
||||
HiBolt,
|
||||
HiChevronDown,
|
||||
HiEllipsisHorizontal,
|
||||
HiOutlinePlusSmall,
|
||||
} from "react-icons/hi2";
|
||||
@@ -20,20 +19,16 @@ import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
|
||||
import { InviteMemberForm } from "./components/InviteMemberForm";
|
||||
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
|
||||
|
||||
export default function MembersPage() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const { data, isLoading } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
@@ -42,31 +37,6 @@ export default function MembersPage() {
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const { canEditMember } = usePermissions();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const updateRoleMutation = api.member.updateRole.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.workspace.byId.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
|
||||
showPopup({
|
||||
header: t`Role updated`,
|
||||
message: t`The member's role has been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to update role`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const subscriptions = data?.subscriptions as Subscription[] | undefined;
|
||||
|
||||
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
||||
@@ -84,7 +54,6 @@ export default function MembersPage() {
|
||||
memberStatus,
|
||||
isLastRow,
|
||||
showSkeleton,
|
||||
showPendingIcon,
|
||||
}: {
|
||||
memberPublicId?: string;
|
||||
memberId?: string | null | undefined;
|
||||
@@ -95,18 +64,7 @@ export default function MembersPage() {
|
||||
memberStatus?: string;
|
||||
isLastRow?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
showPendingIcon?: boolean;
|
||||
}) => {
|
||||
const handleRoleChange = (newRole: "admin" | "member" | "guest") => {
|
||||
if (!memberPublicId) return;
|
||||
|
||||
updateRoleMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId,
|
||||
role: newRole,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
<td
|
||||
@@ -124,7 +82,6 @@ export default function MembersPage() {
|
||||
name={memberName ?? ""}
|
||||
email={memberEmail ?? ""}
|
||||
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
|
||||
icon={showPendingIcon ? "?" : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -136,26 +93,20 @@ export default function MembersPage() {
|
||||
"mr-2 truncate text-xs font-medium text-neutral-900 dark:text-dark-1000 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
showPendingIcon &&
|
||||
"italic text-neutral-500 dark:text-dark-900",
|
||||
)}
|
||||
>
|
||||
{memberName}
|
||||
</p>
|
||||
</div>
|
||||
{((workspace.role === "admin" ||
|
||||
data?.showEmailsToMembers === true) ||
|
||||
showSkeleton) && (
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-xs text-dark-900 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{memberEmail}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-xs text-dark-900 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{memberEmail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,67 +118,32 @@ export default function MembersPage() {
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between px-2 sm:px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{showSkeleton ? (
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
|
||||
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
|
||||
showSkeleton &&
|
||||
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative inline-flex items-center">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]">
|
||||
{memberRole &&
|
||||
memberRole.charAt(0).toUpperCase() +
|
||||
memberRole.slice(1)}
|
||||
{canEditMember && session?.user.id !== memberId && (
|
||||
<HiChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{canEditMember && session?.user.id !== memberId && (
|
||||
<select
|
||||
value={memberRole}
|
||||
onChange={(e) =>
|
||||
handleRoleChange(
|
||||
e.target.value as "admin" | "member" | "guest",
|
||||
)
|
||||
}
|
||||
disabled={updateRoleMutation.isPending}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer appearance-none border-none bg-transparent p-0 text-[10px] leading-none opacity-0 focus:outline-none focus-visible:outline-none sm:text-[11px]"
|
||||
>
|
||||
<option value="admin">{t`Admin`}</option>
|
||||
<option value="member">{t`Member`}</option>
|
||||
<option value="guest">{t`Guest`}</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
>
|
||||
{memberRole &&
|
||||
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
|
||||
</span>
|
||||
{(memberStatus === "invited" || memberStatus === "paused") && (
|
||||
<span className="inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:text-[11px]">
|
||||
<span className="mt-1 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:ml-2 sm:mt-0 sm:text-[11px]">
|
||||
{memberStatus === "invited" ? t`Pending` : t`Paused`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"relative",
|
||||
"relative z-50",
|
||||
(workspace.role !== "admin" || showSkeleton) && "hidden",
|
||||
)}
|
||||
>
|
||||
{session?.user.id !== memberId && (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Edit permissions`,
|
||||
action: () =>
|
||||
openModal(
|
||||
"EDIT_MEMBER_PERMISSIONS",
|
||||
memberPublicId,
|
||||
memberEmail ?? "",
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Remove member`,
|
||||
action: () =>
|
||||
@@ -241,7 +157,7 @@ export default function MembersPage() {
|
||||
>
|
||||
<HiEllipsisHorizontal
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900 sm:size-[20px]"
|
||||
className="text-light-900 dark:text-dark-900 sm:size-[25px]"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
@@ -330,24 +246,19 @@ export default function MembersPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
|
||||
{!isLoading &&
|
||||
data?.members.map((member, index) => {
|
||||
const isPendingInvite = member.status === "invited";
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={member.publicId}
|
||||
memberPublicId={member.publicId}
|
||||
memberId={member.user?.id}
|
||||
memberName={member.user?.name}
|
||||
memberEmail={member.user?.email ?? member.email}
|
||||
memberImage={member.user?.image}
|
||||
memberRole={member.role}
|
||||
memberStatus={member.status}
|
||||
isLastRow={index === data.members.length - 1}
|
||||
showPendingIcon={isPendingInvite}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
data?.members.map((member, index) => (
|
||||
<TableRow
|
||||
key={member.publicId}
|
||||
memberPublicId={member.publicId}
|
||||
memberId={member.user?.id}
|
||||
memberName={member.user?.name}
|
||||
memberEmail={member.user?.email ?? member.email}
|
||||
memberImage={member.user?.image}
|
||||
memberRole={member.role}
|
||||
memberStatus={member.status}
|
||||
isLastRow={index === data.members.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<>
|
||||
@@ -396,14 +307,6 @@ export default function MembersPage() {
|
||||
>
|
||||
<DeleteMemberConfirmation />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "EDIT_MEMBER_PERMISSIONS"}
|
||||
centered
|
||||
>
|
||||
<EditMemberPermissionsModal />
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import Popup from "~/components/Popup";
|
||||
import ThemeToggle from "~/components/ThemeToggle";
|
||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -30,11 +29,6 @@ export default function PublicBoardView() {
|
||||
const { showPopup } = usePopup();
|
||||
const [isRouteLoaded, setIsRouteLoaded] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
const boardSlug = Array.isArray(router.query.boardSlug)
|
||||
? router.query.boardSlug[0]
|
||||
@@ -157,11 +151,7 @@ export default function PublicBoardView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onMouseDown={onMouseDown}
|
||||
className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300"
|
||||
>
|
||||
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
||||
{isLoading || !router.isReady ? (
|
||||
<div className="ml-[2rem] flex">
|
||||
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
@@ -37,13 +37,6 @@ export default function AccountSettings() {
|
||||
<UpdateDisplayNameForm displayName={data?.name ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Email`}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-700 dark:text-dark-900">{data?.email}</p>
|
||||
</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`Language`}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import Button from "~/components/Button";
|
||||
import Modal from "~/components/modal";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { ClearCustomPermissionsConfirmation } from "./components/ClearCustomPermissionsConfirmation";
|
||||
import { RolePermissions } from "./components/RolePermissions";
|
||||
|
||||
export default function PermissionsSettings() {
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal, isOpen, modalContentType } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const isAdmin = workspace.role === "admin";
|
||||
|
||||
const resetAllOverrides = api.permission.resetWorkspaceMemberPermissions.useMutation(
|
||||
{
|
||||
onSuccess: async () => {
|
||||
showPopup({
|
||||
header: t`Overrides cleared`,
|
||||
message: t`All member permission overrides have been reset to their role defaults.`,
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
// Refresh any relevant workspace data
|
||||
await utils.workspace.byId.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to clear overrides`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Settings | Permissions`} />
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Workspace permissions`}
|
||||
</h2>
|
||||
<p className="mb-6 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Configure which actions are allowed for each workspace role. These permissions apply to all members with that role.`}
|
||||
</p>
|
||||
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<RolePermissions />
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-4 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Custom permissions`}
|
||||
</h2>
|
||||
<p className="mb-6 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Clear any custom member permissions so that all members only inherit permissions from their role defaults.`}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!workspace.publicId || resetAllOverrides.isPending) {
|
||||
return;
|
||||
}
|
||||
openModal("CLEAR_CUSTOM_PERMISSIONS");
|
||||
}}
|
||||
disabled={resetAllOverrides.isPending}
|
||||
>
|
||||
{t`Clear custom permissions`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`You need to be an admin to manage workspace permissions.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CLEAR_CUSTOM_PERMISSIONS"}
|
||||
>
|
||||
<ClearCustomPermissionsConfirmation
|
||||
resetAllOverrides={resetAllOverrides}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,11 @@ import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||
@@ -26,7 +24,6 @@ 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);
|
||||
@@ -63,7 +60,6 @@ export default function WorkspaceSettings() {
|
||||
<UpdateWorkspaceNameForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceName={workspace.name}
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
@@ -73,7 +69,6 @@ export default function WorkspaceSettings() {
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceUrl={workspace.slug ?? ""}
|
||||
workspacePlan={workspace.plan ?? "free"}
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
@@ -82,18 +77,6 @@ export default function WorkspaceSettings() {
|
||||
<UpdateWorkspaceDescriptionForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceDescription={workspace.description ?? ""}
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Email visibility`}
|
||||
</h2>
|
||||
<UpdateWorkspaceEmailVisibilityForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
showEmailsToMembers={Boolean(
|
||||
workspaceData?.showEmailsToMembers ?? false,
|
||||
)}
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import type { api } from "~/utils/api";
|
||||
|
||||
type ResetMutation = ReturnType<
|
||||
typeof api.permission.resetWorkspaceMemberPermissions.useMutation
|
||||
>;
|
||||
|
||||
export function ClearCustomPermissionsConfirmation({
|
||||
resetAllOverrides,
|
||||
}: {
|
||||
resetAllOverrides: ResetMutation;
|
||||
}) {
|
||||
const { closeModal } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!workspace.publicId || resetAllOverrides.isPending) return;
|
||||
resetAllOverrides.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{t`Clear all custom permissions?`}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
|
||||
{t`This will remove all custom member permissions in this workspace. Members will inherit permissions only from their roles.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button size="sm" variant="secondary" onClick={() => closeModal()}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
isLoading={resetAllOverrides.isPending}
|
||||
>
|
||||
{t`Clear custom permissions`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { permissionCategories, roles } from "@kan/shared";
|
||||
import type { Permission, Role } from "@kan/shared";
|
||||
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
function formatRoleLabel(role: Role) {
|
||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||
}
|
||||
|
||||
const permissionLabels: Record<Permission, string> = {
|
||||
"workspace:view": t`Can view workspace`,
|
||||
"workspace:edit": t`Can edit workspace`,
|
||||
"workspace:delete": t`Can delete workspace`,
|
||||
"workspace:manage": t`Can manage workspace settings`,
|
||||
|
||||
"board:view": t`Can view boards`,
|
||||
"board:create": t`Can create boards`,
|
||||
"board:edit": t`Can edit boards`,
|
||||
"board:delete": t`Can delete boards`,
|
||||
|
||||
"list:view": t`Can view lists`,
|
||||
"list:create": t`Can create lists`,
|
||||
"list:edit": t`Can edit lists`,
|
||||
"list:delete": t`Can delete lists`,
|
||||
|
||||
"card:view": t`Can view cards`,
|
||||
"card:create": t`Can create cards`,
|
||||
"card:edit": t`Can edit cards`,
|
||||
"card:delete": t`Can delete cards`,
|
||||
|
||||
"comment:view": t`Can view comments`,
|
||||
"comment:create": t`Can add comments`,
|
||||
"comment:edit": t`Can edit comments`,
|
||||
"comment:delete": t`Can delete comments`,
|
||||
|
||||
"member:view": t`Can view members`,
|
||||
"member:invite": t`Can invite members`,
|
||||
"member:edit": t`Can edit member roles and permissions`,
|
||||
"member:remove": t`Can remove members`,
|
||||
};
|
||||
|
||||
export function RolePermissions() {
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data, isLoading } =
|
||||
api.permission.getWorkspaceRolePermissions.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{ enabled: !!workspace.publicId },
|
||||
);
|
||||
|
||||
const systemRoles = (data?.roles ?? []).filter((role) =>
|
||||
(roles).includes(role.name as Role),
|
||||
);
|
||||
|
||||
const orderedRoleNames: Role[] = ["admin", "member", "guest"].filter(
|
||||
(role) => systemRoles.some((r) => r.name === role),
|
||||
) as Role[];
|
||||
|
||||
const grantMutation = api.permission.grantRolePermission.useMutation({
|
||||
onSettled: async () => {
|
||||
if (!workspace.publicId) return;
|
||||
await utils.permission.getWorkspaceRolePermissions.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = api.permission.revokeRolePermission.useMutation({
|
||||
onSettled: async () => {
|
||||
if (!workspace.publicId) return;
|
||||
await utils.permission.getWorkspaceRolePermissions.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isBusy = grantMutation.isPending || revokeMutation.isPending;
|
||||
|
||||
const handleToggle = (
|
||||
rolePublicId: string,
|
||||
permission: Permission,
|
||||
checked: boolean,
|
||||
) => {
|
||||
if (!workspace.publicId || !rolePublicId) return;
|
||||
|
||||
if (checked) {
|
||||
grantMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
rolePublicId,
|
||||
permission,
|
||||
});
|
||||
} else {
|
||||
revokeMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
rolePublicId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
{orderedRoleNames.length === 0 && !isLoading ? (
|
||||
<p className="mb-4 text-sm text-neutral-500 dark:text-dark-800">
|
||||
{t`No roles found for this workspace yet.`}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto rounded-md border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-100">
|
||||
<table className="min-w-full table-fixed divide-y divide-light-600 overflow-visible text-left text-sm dark:divide-dark-600">
|
||||
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-300">
|
||||
<tr>
|
||||
<th className="w-1/2 rounded-tl-lg px-4 py-3 text-left text-xs font-semibold tracking-wide text-light-900 dark:text-dark-900">
|
||||
{t`Permission`}
|
||||
</th>
|
||||
{orderedRoleNames.map((role) => (
|
||||
<th
|
||||
key={role}
|
||||
className="w-1/6 px-4 py-3 text-center text-xs font-semibold tracking-wide text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{formatRoleLabel(role)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
{Object.values(permissionCategories).map((category) => (
|
||||
<tbody
|
||||
key={category.label}
|
||||
className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100"
|
||||
>
|
||||
<tr className="bg-light-100 dark:bg-dark-200">
|
||||
<td
|
||||
colSpan={1 + orderedRoleNames.length}
|
||||
className="px-4 py-2 text-xs font-semibold tracking-wide text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{category.label}
|
||||
</td>
|
||||
</tr>
|
||||
{category.permissions.map((permission) => (
|
||||
<tr key={permission}>
|
||||
<td className="w-1/2 px-4 py-2 text-sm text-light-900 dark:text-dark-900">
|
||||
{permissionLabels[permission] ?? permission}
|
||||
</td>
|
||||
{orderedRoleNames.map((roleName) => {
|
||||
const role = systemRoles.find((r) => r.name === roleName);
|
||||
const checked = role?.permissions.includes(permission);
|
||||
const isAdminRole = roleName === "admin";
|
||||
const isBillingOrDeletePermission =
|
||||
permission === "workspace:manage" ||
|
||||
permission === "workspace:delete";
|
||||
|
||||
return (
|
||||
<td
|
||||
key={roleName}
|
||||
className="w-1/6 px-4 py-2 text-center align-middle"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500 disabled:opacity-60"
|
||||
disabled={
|
||||
isAdminRole ||
|
||||
isBillingOrDeletePermission ||
|
||||
!role ||
|
||||
isLoading ||
|
||||
isBusy
|
||||
}
|
||||
checked={!!checked}
|
||||
onChange={(e) =>
|
||||
!isAdminRole &&
|
||||
!isBillingOrDeletePermission &&
|
||||
role &&
|
||||
handleToggle(
|
||||
role.publicId,
|
||||
permission,
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,9 @@ import { api } from "~/utils/api";
|
||||
const UpdateWorkspaceDescriptionForm = ({
|
||||
workspacePublicId,
|
||||
workspaceDescription,
|
||||
disabled = false,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
workspaceDescription: string;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
@@ -80,10 +78,9 @@ const UpdateWorkspaceDescriptionForm = ({
|
||||
<Input
|
||||
{...register("description")}
|
||||
errorMessage={errors.description?.message}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{isDirty && !disabled && (
|
||||
{isDirty && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function UpdateWorkspaceEmailVisibilityForm({
|
||||
workspacePublicId,
|
||||
showEmailsToMembers,
|
||||
disabled = false,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
showEmailsToMembers: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [isChecked, setIsChecked] = useState(showEmailsToMembers);
|
||||
|
||||
useEffect(() => {
|
||||
setIsChecked(showEmailsToMembers);
|
||||
}, [showEmailsToMembers]);
|
||||
|
||||
const updateWorkspace = api.workspace.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.workspace.byId.invalidate({
|
||||
workspacePublicId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled) return;
|
||||
const newValue = !isChecked;
|
||||
setIsChecked(newValue);
|
||||
updateWorkspace.mutate({
|
||||
workspacePublicId,
|
||||
showEmailsToMembers: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Allow workspace members to see each other's email addresses`}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
isChecked={isChecked}
|
||||
onChange={handleToggle}
|
||||
label=""
|
||||
disabled={disabled || updateWorkspace.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,11 +20,9 @@ type FormValues = z.infer<typeof schema>;
|
||||
const UpdateWorkspaceNameForm = ({
|
||||
workspacePublicId,
|
||||
workspaceName,
|
||||
disabled = false,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
workspaceName: string;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
@@ -72,13 +70,9 @@ const UpdateWorkspaceNameForm = ({
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
|
||||
<Input
|
||||
{...register("name")}
|
||||
errorMessage={errors.name?.message}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input {...register("name")} errorMessage={errors.name?.message} />
|
||||
</div>
|
||||
{isDirty && !disabled && (
|
||||
{isDirty && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
|
||||
@@ -16,12 +16,10 @@ const UpdateWorkspaceUrlForm = ({
|
||||
workspacePublicId,
|
||||
workspaceUrl,
|
||||
workspacePlan,
|
||||
disabled = false,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
workspaceUrl: string;
|
||||
workspacePlan: "free" | "pro" | "enterprise";
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
@@ -138,10 +136,9 @@ const UpdateWorkspaceUrlForm = ({
|
||||
<HiCheck className="h-4 w-4 dark:text-dark-1000" />
|
||||
) : null
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{isDirty && !disabled && (
|
||||
{isDirty && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
|
||||
@@ -9,8 +9,6 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-}
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
@@ -21,7 +19,6 @@ services:
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
- NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
|
||||
# Stripe
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||
@@ -56,15 +53,11 @@ services:
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||
- NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=${NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS}
|
||||
|
||||
# Auth config (optional)
|
||||
# Auth config
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
|
||||
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
|
||||
|
||||
# API configuration (optional)
|
||||
- NEXT_API_BODY_SIZE_LIMIT=${NEXT_API_BODY_SIZE_LIMIT}
|
||||
|
||||
# Integration providers
|
||||
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
|
||||
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
|
||||
|
||||
@@ -18,9 +18,6 @@ services:
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
|
||||
# Redis (optional - for rate limiting)
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
|
||||
# Admin API key (optional)
|
||||
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
|
||||
|
||||
@@ -45,7 +42,6 @@ services:
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||
- NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=${NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS}
|
||||
|
||||
# White label
|
||||
- NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY=${NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY}
|
||||
@@ -54,9 +50,6 @@ services:
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
|
||||
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
|
||||
|
||||
# API configuration (optional)
|
||||
- NEXT_API_BODY_SIZE_LIMIT=${NEXT_API_BODY_SIZE_LIMIT}
|
||||
|
||||
# Integration providers (optional)
|
||||
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
|
||||
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
|
||||
|
||||
@@ -23,10 +23,6 @@
|
||||
"./openapi": {
|
||||
"types": "./dist/openapi.d.ts",
|
||||
"default": "./src/openapi.ts"
|
||||
},
|
||||
"./utils/rateLimit": {
|
||||
"types": "./dist/utils/rateLimit.d.ts",
|
||||
"default": "./src/utils/rateLimit.ts"
|
||||
}
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
@@ -47,7 +43,6 @@
|
||||
"@kan/shared": "workspace:^",
|
||||
"@kan/stripe": "workspace:^",
|
||||
"@trpc/server": "catalog:",
|
||||
"rate-limiter-flexible": "^9.0.1",
|
||||
"superjson": "2.2.1",
|
||||
"trpc-to-openapi": "^2.3.2",
|
||||
"zod": "catalog:"
|
||||
|
||||
@@ -9,7 +9,6 @@ import { integrationRouter } from "./routers/integration";
|
||||
import { labelRouter } from "./routers/label";
|
||||
import { listRouter } from "./routers/list";
|
||||
import { memberRouter } from "./routers/member";
|
||||
import { permissionRouter } from "./routers/permission";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { workspaceRouter } from "./routers/workspace";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
@@ -25,7 +24,6 @@ export const appRouter = createTRPCRouter({
|
||||
list: listRouter,
|
||||
member: memberRouter,
|
||||
import: importRouter,
|
||||
permission: permissionRouter,
|
||||
user: userRouter,
|
||||
workspace: workspaceRouter,
|
||||
integration: integrationRouter,
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
import { deleteObject, generateUploadUrl } from "../utils/s3";
|
||||
|
||||
export const attachmentRouter = createTRPCRouter({
|
||||
@@ -55,7 +55,8 @@ export const attachmentRouter = createTRPCRouter({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
|
||||
// Get workspace publicId
|
||||
const workspace = await workspaceRepo.getById(ctx.db, card.workspaceId);
|
||||
@@ -130,7 +131,8 @@ export const attachmentRouter = createTRPCRouter({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
|
||||
const attachment = await cardAttachmentRepo.create(ctx.db, {
|
||||
cardId: card.id,
|
||||
@@ -184,7 +186,8 @@ export const attachmentRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
const workspaceId = attachment.card.list.board.workspaceId;
|
||||
await assertPermission(ctx.db, userId, workspaceId, "card:edit");
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspaceId);
|
||||
|
||||
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||
if (bucket) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
@@ -58,14 +58,11 @@ export const boardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "board:view");
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
|
||||
const result = boardRepo.getAllByWorkspaceId(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
userId,
|
||||
{ type: input.type }
|
||||
);
|
||||
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id, {
|
||||
type: input.type,
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
@@ -122,7 +119,7 @@ export const boardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, board.workspaceId, "board:view");
|
||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
||||
|
||||
// Convert semantic string filters to date ranges expected by the repo
|
||||
const dueDateFilters = input.dueDateFilters
|
||||
@@ -132,7 +129,6 @@ export const boardRouter = createTRPCRouter({
|
||||
const result = await boardRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.boardPublicId,
|
||||
userId,
|
||||
{
|
||||
members: input.members ?? [],
|
||||
labels: input.labels ?? [],
|
||||
@@ -259,7 +255,7 @@ export const boardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "board:create");
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
|
||||
// If sourceBoardPublicId is provided, clone the source board
|
||||
if (input.sourceBoardPublicId) {
|
||||
@@ -279,7 +275,6 @@ export const boardRouter = createTRPCRouter({
|
||||
const sourceBoard = await boardRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.sourceBoardPublicId,
|
||||
userId,
|
||||
{
|
||||
members: [],
|
||||
labels: [],
|
||||
@@ -404,10 +399,9 @@ export const boardRouter = createTRPCRouter({
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||
.optional(),
|
||||
visibility: z.enum(["public", "private"]).optional(),
|
||||
favorite: z.boolean().optional()
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -428,30 +422,7 @@ export const boardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertCanEdit(
|
||||
ctx.db,
|
||||
userId,
|
||||
board.workspaceId,
|
||||
"board:edit",
|
||||
board.createdBy ?? null,
|
||||
);
|
||||
|
||||
// Handle favorite toggle separately
|
||||
if (input.favorite !== undefined) {
|
||||
if (input.favorite) {
|
||||
await boardRepo.addUserFavorite(ctx.db, userId, board.id);
|
||||
} else {
|
||||
await boardRepo.removeUserFavorite(ctx.db, userId, board.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other updates (name, slug, visibility)
|
||||
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined;
|
||||
|
||||
if (!hasOtherUpdates) {
|
||||
// Only favorite was updated, return success
|
||||
return { success: true };
|
||||
}
|
||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
||||
|
||||
if (input.slug) {
|
||||
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
||||
@@ -520,13 +491,7 @@ export const boardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertCanDelete(
|
||||
ctx.db,
|
||||
userId,
|
||||
board.workspaceId,
|
||||
"board:delete",
|
||||
board.createdBy ?? null,
|
||||
);
|
||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
||||
|
||||
const listIds = board.lists.map((list) => list.id);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { mergeActivities } from "../utils/activities";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
import { generateDownloadUrl } from "../utils/s3";
|
||||
|
||||
export const cardRouter = createTRPCRouter({
|
||||
@@ -57,7 +57,13 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, list.workspaceId, "card:create");
|
||||
await assertUserInWorkspace(ctx.db, userId, list.workspaceId);
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const newCard = await cardRepo.create(ctx.db, {
|
||||
title: input.title,
|
||||
@@ -193,7 +199,7 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "comment:create");
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
|
||||
const newComment = await cardCommentRepo.create(ctx.db, {
|
||||
comment: input.comment,
|
||||
@@ -256,6 +262,8 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
|
||||
const existingComment = await cardCommentRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.commentPublicId,
|
||||
@@ -267,13 +275,11 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertCanEdit(
|
||||
ctx.db,
|
||||
userId,
|
||||
card.workspaceId,
|
||||
"comment:edit",
|
||||
existingComment.createdBy,
|
||||
);
|
||||
if (existingComment.createdBy !== userId)
|
||||
throw new TRPCError({
|
||||
message: `You do not have permission to update this comment`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
|
||||
const updatedComment = await cardCommentRepo.update(ctx.db, {
|
||||
id: existingComment.id,
|
||||
@@ -334,6 +340,8 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
|
||||
const existingComment = await cardCommentRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.commentPublicId,
|
||||
@@ -345,14 +353,6 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertCanDelete(
|
||||
ctx.db,
|
||||
userId,
|
||||
card.workspaceId,
|
||||
"comment:delete",
|
||||
existingComment.createdBy,
|
||||
);
|
||||
|
||||
const deletedComment = await cardCommentRepo.softDelete(ctx.db, {
|
||||
commentId: existingComment.id,
|
||||
deletedAt: new Date(),
|
||||
@@ -412,7 +412,7 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
|
||||
const label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
|
||||
|
||||
@@ -504,7 +504,7 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
|
||||
const member = await workspaceRepo.getMemberByPublicId(
|
||||
ctx.db,
|
||||
@@ -616,7 +616,7 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "card:view");
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
}
|
||||
|
||||
const result = await cardRepo.getWithListAndMembersByPublicId(
|
||||
@@ -725,7 +725,7 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "card:view");
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
}
|
||||
|
||||
const cursor = input.cursor ? new Date(input.cursor) : undefined;
|
||||
@@ -788,13 +788,7 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertCanEdit(
|
||||
ctx.db,
|
||||
userId,
|
||||
card.workspaceId,
|
||||
"card:edit",
|
||||
card.createdBy,
|
||||
);
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
|
||||
const existingCard = await cardRepo.getByPublicId(
|
||||
ctx.db,
|
||||
@@ -964,13 +958,7 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertCanDelete(
|
||||
ctx.db,
|
||||
userId,
|
||||
card.workspaceId,
|
||||
"card:delete",
|
||||
card.createdBy,
|
||||
);
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
|
||||
const deletedAt = new Date();
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as checklistRepo from "@kan/db/repository/checklist.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
|
||||
const checklistSchema = z.object({
|
||||
publicId: z.string().length(12),
|
||||
@@ -57,7 +57,8 @@ export const checklistRouter = createTRPCRouter({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
|
||||
const newChecklist = await checklistRepo.create(ctx.db, {
|
||||
name: input.name,
|
||||
@@ -105,11 +106,11 @@ export const checklistRouter = createTRPCRouter({
|
||||
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await assertPermission(
|
||||
|
||||
await assertUserInWorkspace(
|
||||
ctx.db,
|
||||
userId,
|
||||
checklist.card.list.board.workspace.id,
|
||||
"card:edit",
|
||||
);
|
||||
|
||||
const previousName = checklist.name;
|
||||
@@ -165,11 +166,11 @@ export const checklistRouter = createTRPCRouter({
|
||||
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await assertPermission(
|
||||
|
||||
await assertUserInWorkspace(
|
||||
ctx.db,
|
||||
userId,
|
||||
checklist.card.list.board.workspace.id,
|
||||
"card:edit",
|
||||
);
|
||||
|
||||
await checklistRepo.softDeleteAllItemsByChecklistId(ctx.db, {
|
||||
@@ -236,11 +237,11 @@ export const checklistRouter = createTRPCRouter({
|
||||
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await assertPermission(
|
||||
|
||||
await assertUserInWorkspace(
|
||||
ctx.db,
|
||||
userId,
|
||||
checklist.card.list.board.workspace.id,
|
||||
"card:edit",
|
||||
);
|
||||
|
||||
const newChecklistItem = await checklistRepo.createItem(ctx.db, {
|
||||
@@ -303,11 +304,11 @@ export const checklistRouter = createTRPCRouter({
|
||||
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await assertPermission(
|
||||
|
||||
await assertUserInWorkspace(
|
||||
ctx.db,
|
||||
userId,
|
||||
item.checklist.card.list.board.workspace.id,
|
||||
"card:edit",
|
||||
);
|
||||
|
||||
const previousTitle = item.title;
|
||||
@@ -393,11 +394,11 @@ export const checklistRouter = createTRPCRouter({
|
||||
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await assertPermission(
|
||||
|
||||
await assertUserInWorkspace(
|
||||
ctx.db,
|
||||
userId,
|
||||
item.checklist.card.list.board.workspace.id,
|
||||
"card:edit",
|
||||
);
|
||||
|
||||
const deleted = await checklistRepo.softDeleteItemById(ctx.db, {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user